{"slug": "audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig", "title": "Audit Agent File Reads Before a Remote Model Sees Your kubeconfig", "summary": "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.", "body_md": "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.\n\nThat 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?\n\nThis 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.**\n\nHere is the event chain I keep seeing in traces, including MCP filesystem servers and homegrown `read_file` tools:\n\n`docker-compose.yml`, then `.env`, then a kubeconfig \"just to check the port\".` messages[]`.` client-key-data`, and `MYSQL_ROOT_PASSWORD`.\nStep 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?\n\nTreat 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.**\n\nAssets: compose passwords, `.env` values, kubeconfig user keys, SSH private keys, cloud access keys, customer identifiers that happen to sit in fixtures.\n\nActors: a developer, an IDE agent, an MCP `filesystem` server, a remote completion API, and whoever can read conversation logs later.\n\nTrust boundaries:\n\n| Boundary | What crosses it | Default control | Failure mode | \n|---|---|---|---|\n| Workspace → agent tool | Raw file bytes | None, if `read_file` is unconstrained | Agent opens `.env` because the user said \"debug boot\" | \n| Tool result → `messages[]` | Full file, no redaction | Maybe a token cap | Secret survives truncation if it sits near the top | \n| Harness → remote model | HTTPS JSON | TLS only | Provider, proxy, or support log now holds the secret | \n| Model → your logs | Echoed values, diffs, \"fixed compose\" | Debug logging | You re-leak the secret into CI artifacts | \n\nPositive 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`.\n\nIf your scanner cannot tell those two apart, you do not have a gate. You have a word filter.\n\nPinned for this lab: **Python 3.12**, stdlib only. Unexecuted against your laptop until you run it. Label it that way in review comments.\n\nCreate a tiny workspace:\n\n```\nlab-agent-context/\n  README.md                 # positive\n  docker-compose.yml        # negative\n  kubeconfig.canary         # negative\n  notes.txt                 # positive\n  scan_agent_context.py\n  test_scan_agent_context.py\n```\n\n`README.md`:\n\n```\n# lab-agent-context\nSet the database password in your secrets manager.\nDo not commit .env files.\n```\n\n`notes.txt`:\n\n```\nBind mount is ./api:/app. Restart the api service after changing ports.\n```\n\n`docker-compose.yml` (canary only — rotate if you ever paste this into a real chat):\n\n```\nservices:\n  db:\n    image: postgres:16\n    environment:\n      MYSQL_ROOT_PASSWORD: \"canary-compose-password-not-for-prod\"\n      DATABASE_URL: \"postgres://app:canary-db-url-not-for-prod@db:5432/app\"\n```\n\nYes, the image is Postgres and the variable says MySQL. Agents copy that kind of mess into prompts all the time. That is the point.\n\n`kubeconfig.canary` (fake key material, still treated as secret-shaped):\n\n```\napiVersion: v1\nkind: Config\nclusters:\n  - name: lab\n    cluster:\n      server: https://127.0.0.1:6443\nusers:\n  - name: lab-admin\n    user:\n      client-key-data: Y2FuYXJ5LWt1YmUtY2xpZW50LWtleS1ub3QtZm9yLXByb2Q=\nbash\n#!/usr/bin/env python3\n\"\"\"Pre-flight gate for agent file reads. Lab fixture, not a secret scanner product.\"\"\"\nfrom __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nDENY_NAMES = {\n    \".env\",\n    \".env.local\",\n    \"id_rsa\",\n    \"id_ed25519\",\n    \"credentials\",\n}\nDENY_SUFFIXES = {\n    \".pem\",\n    \".p12\",\n    \".kubeconfig\",\n}\nDENY_BASENAME_SUBSTR = (\"kubeconfig\",)\n\nCONTENT_RULES = (\n    (\"private_key\", re.compile(r\"BEGIN (?:OPENSSH |RSA )?PRIVATE KEY\")),\n    (\"aws_access_key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n    (\"github_pat\", re.compile(r\"\\bgh[pousr]_[A-Za-z0-9_]{20,}\\b\")),\n    (\"client_key_data\", re.compile(r\"\\bclient-key-data\\s*:\\s*\\S+\")),\n    (\"jdbc_or_pg_url\", re.compile(r\"\\b(?:postgres|mysql|mongodb)://[^\\s]+\", re.I)),\n    (\n        \"assignment_password\",\n        re.compile(\n            r\"(?im)^\\s*(?:password|passwd|mysql_root_password|secret_key)\\s*[:=]\\s*\\S+\"\n        ),\n    ),\n)\n\nDOC_PASSWORD = re.compile(\n    r\"set (?:the |your )?.*password|do not commit|secrets manager\",\n    re.I,\n)\n\ndef path_denied(path: Path) -> str | None:\n    name = path.name.lower()\n    if name in DENY_NAMES:\n        return f\"deny-name:{name}\"\n    if path.suffix.lower() in DENY_SUFFIXES:\n        return f\"deny-suffix:{path.suffix}\"\n    if any(s in name for s in DENY_BASENAME_SUBSTR):\n        return f\"deny-kubeconfig-name:{name}\"\n    return None\n\ndef content_denied(text: str) -> str | None:\n    # Allow documentation that talks about passwords without embedding one.\n    if DOC_PASSWORD.search(text) and not re.search(r\"[:=]\\s*\\S+\", text):\n        return None\n    for label, rx in CONTENT_RULES:\n        if rx.search(text):\n            return f\"deny-content:{label}\"\n    return None\n\ndef audit_file(path: Path) -> str | None:\n    hit = path_denied(path)\n    if hit:\n        return hit\n    text = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n    return content_denied(text)\n\ndef audit_paths(paths: list[Path]) -> list[tuple[str, str]]:\n    findings = []\n    for path in paths:\n        hit = audit_file(path)\n        if hit:\n            findings.append((str(path), hit))\n    return findings\n\nif __name__ == \"__main__\":\n    import sys\n\n    targets = [Path(p) for p in sys.argv[1:]]\n    findings = audit_paths(targets)\n    if not findings:\n        print(\"PASS: no denied files in proposed agent context\")\n        raise SystemExit(0)\n    print(\"FAIL: refused to copy these paths into model context\")\n    for path, reason in findings:\n        print(f\"  {path}: {reason}\")\n    raise SystemExit(1)\n```\n\nExpected failure evidence when you pass the negative files (run it; do not take my word):\n\n```\npython3.12 scan_agent_context.py README.md notes.txt\n# PASS: no denied files in proposed agent context\n\npython3.12 scan_agent_context.py README.md docker-compose.yml kubeconfig.canary\n# FAIL: refused to copy these paths into model context\n#   docker-compose.yml: deny-content:assignment_password\n#   kubeconfig.canary: deny-kubeconfig-name:kubeconfig.canary\n```\n\nIf the second command prints `PASS`, your gate is dead. Do not \"fix\" that by sending the files to a model to ask why.\n\n``` python\nfrom pathlib import Path\n\nfrom scan_agent_context import audit_file, audit_paths\n\nROOT = Path(__file__).parent\n\ndef test_positive_readme_is_allowed():\n    assert audit_file(ROOT / \"README.md\") is None\n\ndef test_positive_notes_are_allowed():\n    assert audit_file(ROOT / \"notes.txt\") is None\n\ndef test_negative_compose_is_denied():\n    hit = audit_file(ROOT / \"docker-compose.yml\")\n    assert hit == \"deny-content:assignment_password\"\n\ndef test_negative_kubeconfig_is_denied():\n    hit = audit_file(ROOT / \"kubeconfig.canary\")\n    assert hit.startswith(\"deny-kubeconfig-name:\")\n\ndef test_batch_fails_closed():\n    findings = audit_paths(\n        [ROOT / \"README.md\", ROOT / \"docker-compose.yml\", ROOT / \"notes.txt\"]\n    )\n    assert [p for p, _ in findings] == [str(ROOT / \"docker-compose.yml\")]\npython3.12 -m pytest test_scan_agent_context.py -q\n```\n\nWire 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.\n\nI 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.\n\nDisclosure: 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.\n\nIf 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.**\n\nWhat I would still refuse to send, even to a server I started myself:\n\n`::add-mask::` failures.`**/*` was the glob.\nFree tokens do not change classification. They change your bill.\n\n| Layer | Prevent | Detect | Recover | \n|---|---|---|---|\n| 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 | \n| 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 | \n| 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 | \n| 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 | \n\nNotice 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?\n\nThis 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.\n\nDo 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.\n\nAlso 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.\n\nWhich 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.", "url": "https://wpnews.pro/news/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig", "canonical_source": "https://dev.to/jaryn_123/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig-4a3p", "published_at": "2026-09-09 15:23:53+00:00", "updated_at": "2026-09-09 15:48:28.297237+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "artificial-intelligence"], "entities": ["MCP", "kubeconfig", "docker-compose", "Postgres", "MySQL"], "alternates": {"html": "https://wpnews.pro/news/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig", "markdown": "https://wpnews.pro/news/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig.md", "text": "https://wpnews.pro/news/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig.txt", "jsonld": "https://wpnews.pro/news/audit-agent-file-reads-before-a-remote-model-sees-your-kubeconfig.jsonld"}}