cd /news/ai-agents/audit-agent-file-reads-before-a-remo… · home topics ai-agents article
[ARTICLE · art-124739] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Audit Agent File Reads Before a Remote Model Sees Your kubeconfig

A developer demonstrates how AI coding agents can inadvertently leak secrets such as database passwords and kubeconfig keys by reading local files and sending them to remote models. The lab walkthrough highlights the trust boundary between local workspace and remote model context, proposing a scanner to filter sensitive content before it reaches the model.

by read8 min views2 publishedSep 9, 2026

I asked an agent one question: "why won't this stack start?" It called read_file on docker-compose.yml. Then it read .env. Then both blobs went into a remote completion request. The model answered. The database password went with the prompt.

That is not a novel exploit. It is a default tool loop. Local disk and a remote model are different trust domains, free endpoint or not. If your harness can read the workspace, you already moved a boundary. Did you notice?

This walkthrough is a lab. I am not claiming a CVE, a production incident, or a named vendor leak. I am claiming something dumber and more common: file-read tools collapse the workspace into model context unless you enforce an invariant before the HTTP body is built.

Here is the event chain I keep seeing in traces, including MCP filesystem servers and homegrown read_file tools:

docker-compose.yml, then .env, then a kubeconfig "just to check the port". messages[]. client-key-data, and MYSQL_ROOT_PASSWORD. Step 3 is where people argue about product features. Step 5 is the actual trust-boundary violation. The model never needed the secret to explain a bind-mount typo. So why was the secret in the prompt?

Treat the remote model as an unmanaged log sink. Free does not mean on-box. Paid does not mean your DPA covers every paste. Self-hosted still has operators, disks, and debug dumps. The invariant is the same: secrets do not cross the harness → model boundary.

Assets: compose passwords, .env values, kubeconfig user keys, SSH private keys, cloud access keys, customer identifiers that happen to sit in fixtures.

Actors: a developer, an IDE agent, an MCP filesystem server, a remote completion API, and whoever can read conversation logs later.

Trust boundaries:

Boundary What crosses it Default control Failure mode
Workspace → agent tool Raw file bytes None, if read_file is unconstrained Agent opens .env because the user said "debug boot"
Tool result → messages[] Full file, no redaction Maybe a token cap Secret survives truncation if it sits near the top
Harness → remote model HTTPS JSON TLS only Provider, proxy, or support log now holds the secret
Model → your logs Echoed values, diffs, "fixed compose" Debug logging You re-leak the secret into CI artifacts

Positive fixture (should be allowed into context): a README that says "set the database password in your secrets manager". Negative fixture (must be denied): a compose file with a real-looking password, or any kubeconfig users[].user.client-key-data.

If your scanner cannot tell those two apart, you do not have a gate. You have a word filter.

Pinned for this lab: Python 3.12, stdlib only. Unexecuted against your laptop until you run it. Label it that way in review comments.

Create a tiny workspace:

lab-agent-context/
  README.md                 # positive
  docker-compose.yml        # negative
  kubeconfig.canary         # negative
  notes.txt                 # positive
  scan_agent_context.py
  test_scan_agent_context.py

README.md:

Set the database password in your secrets manager.
Do not commit .env files.

notes.txt:

Bind mount is ./api:/app. Restart the api service after changing ports.

docker-compose.yml (canary only — rotate if you ever paste this into a real chat):

services:
  db:
    image: postgres:16
    environment:
      MYSQL_ROOT_PASSWORD: "canary-compose-password-not-for-prod"
      DATABASE_URL: "postgres://app:canary-db-url-not-for-prod@db:5432/app"

Yes, the image is Postgres and the variable says MySQL. Agents copy that kind of mess into prompts all the time. That is the point.

kubeconfig.canary (fake key material, still treated as secret-shaped):

apiVersion: v1
kind: Config
clusters:
  - name: lab
    cluster:
      server: https://127.0.0.1:6443
users:
  - name: lab-admin
    user:
      client-key-data: Y2FuYXJ5LWt1YmUtY2xpZW50LWtleS1ub3QtZm9yLXByb2Q=
bash
#!/usr/bin/env python3
"""Pre-flight gate for agent file reads. Lab fixture, not a secret scanner product."""
from __future__ import annotations

import re
from pathlib import Path

DENY_NAMES = {
    ".env",
    ".env.local",
    "id_rsa",
    "id_ed25519",
    "credentials",
}
DENY_SUFFIXES = {
    ".pem",
    ".p12",
    ".kubeconfig",
}
DENY_BASENAME_SUBSTR = ("kubeconfig",)

CONTENT_RULES = (
    ("private_key", re.compile(r"BEGIN (?:OPENSSH |RSA )?PRIVATE KEY")),
    ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("github_pat", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")),
    ("client_key_data", re.compile(r"\bclient-key-data\s*:\s*\S+")),
    ("jdbc_or_pg_url", re.compile(r"\b(?:postgres|mysql|mongodb)://[^\s]+", re.I)),
    (
        "assignment_password",
        re.compile(
            r"(?im)^\s*(?:password|passwd|mysql_root_password|secret_key)\s*[:=]\s*\S+"
        ),
    ),
)

DOC_PASSWORD = re.compile(
    r"set (?:the |your )?.*password|do not commit|secrets manager",
    re.I,
)

def path_denied(path: Path) -> str | None:
    name = path.name.lower()
    if name in DENY_NAMES:
        return f"deny-name:{name}"
    if path.suffix.lower() in DENY_SUFFIXES:
        return f"deny-suffix:{path.suffix}"
    if any(s in name for s in DENY_BASENAME_SUBSTR):
        return f"deny-kubeconfig-name:{name}"
    return None

def content_denied(text: str) -> str | None:
    if DOC_PASSWORD.search(text) and not re.search(r"[:=]\s*\S+", text):
        return None
    for label, rx in CONTENT_RULES:
        if rx.search(text):
            return f"deny-content:{label}"
    return None

def audit_file(path: Path) -> str | None:
    hit = path_denied(path)
    if hit:
        return hit
    text = path.read_text(encoding="utf-8", errors="replace")
    return content_denied(text)

def audit_paths(paths: list[Path]) -> list[tuple[str, str]]:
    findings = []
    for path in paths:
        hit = audit_file(path)
        if hit:
            findings.append((str(path), hit))
    return findings

if __name__ == "__main__":
    import sys

    targets = [Path(p) for p in sys.argv[1:]]
    findings = audit_paths(targets)
    if not findings:
        print("PASS: no denied files in proposed agent context")
        raise SystemExit(0)
    print("FAIL: refused to copy these paths into model context")
    for path, reason in findings:
        print(f"  {path}: {reason}")
    raise SystemExit(1)

Expected failure evidence when you pass the negative files (run it; do not take my word):

python3.12 scan_agent_context.py README.md notes.txt

python3.12 scan_agent_context.py README.md docker-compose.yml kubeconfig.canary

If the second command prints PASS, your gate is dead. Do not "fix" that by sending the files to a model to ask why.

from pathlib import Path

from scan_agent_context import audit_file, audit_paths

ROOT = Path(__file__).parent

def test_positive_readme_is_allowed():
    assert audit_file(ROOT / "README.md") is None

def test_positive_notes_are_allowed():
    assert audit_file(ROOT / "notes.txt") is None

def test_negative_compose_is_denied():
    hit = audit_file(ROOT / "docker-compose.yml")
    assert hit == "deny-content:assignment_password"

def test_negative_kubeconfig_is_denied():
    hit = audit_file(ROOT / "kubeconfig.canary")
    assert hit.startswith("deny-kubeconfig-name:")

def test_batch_fails_closed():
    findings = audit_paths(
        [ROOT / "README.md", ROOT / "docker-compose.yml", ROOT / "notes.txt"]
    )
    assert [p for p, _ in findings] == [str(ROOT / "docker-compose.yml")]
python3.12 -m pytest test_scan_agent_context.py -q

Wire the same function into the harness before you append a tool result to messages. After the HTTPS call is the wrong layer. You cannot un-send a prompt.

I use self-hosted and remote coding models the same way I use CI runners: assume the job can see only what the gate allowed. A free completion endpoint does not move kubeconfig into a safer domain. It moves it into someone else's.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source AI development platform. The operator-supplied facts I will use, and only those, are that it currently offers free model access and a free server option. I am not pinning model names, token quotas, hardware, uptime, or duration here — those change, and stale numbers are worse than no numbers.

If you need a box to run this fixture against a throwaway workspace, that free server option is one place to park the gate. It is not a substitute for the gate. Remote-free and self-hosted-free share a rule: the model may see the repo subset you explicitly allow, not the repo subset the agent felt like opening.

What I would still refuse to send, even to a server I started myself:

::add-mask:: failures.**/* was the glob. Free tokens do not change classification. They change your bill.

Layer Prevent Detect Recover
Paths Deny .env ,id_* ,*kubeconfig* ,*.pem in the tool allowlist CI test above on a canary tree Delete the file from agent memory stores; you still rotate
Content Strip PASSWORD= , URLs with userinfo,client-key-data Fail closed on first hit; no "ask the model if this is secret" Rotate the canary and every sibling credential in that file
Harness Cap tool fan-out; no recursive $HOME Log tool names and paths, never values, at info level Treat the conversation id as compromised storage
Model Prefer on-box models for anything that touched infra files Red-team with the negative fixtures on every harness change Provider deletion requests are not cryptographic erasure

Notice the empty cell you wanted: there is no recover step that makes a remote prompt un-seen. Rotation is the recovery. Logging the secret again in the incident ticket is a second leak. I have watched that happen. Have you checked your last "agent debug" gist?

This regex gate is a regression fixture. It will miss unstructured secrets, miss encodings, and false-positive on some docs if you loosen DOC_PASSWORD. It will not replace vaults, IAM, or a real secret scanner in CI.

Do not use this approach as your only control if you are in a regulated environment that forbids sending repo contents to a third party at all. Do not grant an MCP filesystem server ~ and then hope the deny list is complete. Do not paste the failing compose file into a public model to "see if it notices". That is how the lab becomes a leak.

Also do not confuse self-hosting with secrecy. Root on the box still reads GPU swap, still reads chat SQLite, still reads nginx access logs if you were sloppy with URLs. The invariant belongs in the harness and in CI, not in a banner that says the model is free.

Which check is an enforceable invariant in CI — "compose files with assignment-shaped passwords never enter messages[]" — and which layer should own it: the MCP tool, the agent harness, or the model gateway? If your answer is "the model will be careful", you do not have a boundary. You have a wish.

── more in #ai-agents 4 stories · sorted by recency
── more on @mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/audit-agent-file-rea…] indexed:0 read:8min 2026-09-09 ·