DEV Community

Riley Lin
Riley Lin

Posted on

The Chat Panel Is Not the Record

You already treat the repository as a trust boundary, yet the coding session copies secrets through a quieter channel. A key that never lands in git can still leave inside a prompt, a retry body, or a client log. If you only scan commits and ignore transcripts, you are reviewing the wrong artifact for assistant-assisted work. The durable record is not the chat widget you closed; it is every copy that widget left behind.

Think of the assistant as a courier hired for one hallway, not as a vault inside your office. The courier is useful and often inexpensive, but every envelope still crosses a door you do not control. Your job is not to fire the courier; it is to decide which pages may leave the building. You also need a record of that decision, because memory is not an audit control.

Most teams draw threat models around production services, then paste a stack trace into a model and stop thinking. The session path is shorter than a payment flow, but it still has four hops that matter for privacy. The editor holds the buffer, a packer builds the prompt, a network call leaves your process, and some log keeps a photocopy. Each hop has a different owner, a different retention clock, and a different person who will swear the file was deleted.

A .gitignore rule cannot save you on this path, because ignore files never see the HTTP body or the editor history. Undo stacks live on disk after you clear the visible thread, and debug clients log request bodies when a call fails at two in the morning. The remote side may keep prompts for abuse review even when you never asked anyone to train on your code. You cannot audit a hop you refuse to name, so name the hops before you paste another “minimal” reproduction.

A useful analogy is airport security for documents, not a debate about whether the model has a trustworthy personality. You are asking which bag is allowed on the plane, who photocopies the bag at the gate, and how long that photocopy sits in a drawer. Once you phrase the work that way, “just this once” stops sounding like a control and starts sounding like an unlabeled transfer. Unlabeled transfers are how secrets become folklore instead of incidents.

A coding model needs signatures, types, failing assertions, and the smallest fragment that still reproduces the fault in your head. It does not need production connection strings, session cookies, customer rows, or internal hostnames that map your network like a floor plan. Those extras feel helpful because they sit beside the bug, the way a badge lanyard sits beside a laptop on a cafe table. They remain credentials even when they appear in a comment, a fixture, or a “temporary” compose block.

Watch for quiet shapes that sneak into prompts more often than a raw .env file ever does in review. A JWT that you called redacted can still carry a readable payload, and a sample compose file can still point at a real internal name. A test dump may include one live customer email because somebody copied a staging row into the ticket. None of those look like AWS_SECRET_ACCESS_KEY, which is why a single grep for that string fails and then everyone relaxes.

Retry behavior belongs in the same threat model, not in a usability footnote about impatient chat. The first prompt might be clean because you were careful and slightly proud of yourself. The fifth retry grows because you paste a bit more context after the model guesses wrong in a confident tone. That extra context is where cookies, private URLs, and log excerpts tend to appear, like lint in a coat pocket after a long day. If you cannot explain why a paragraph must leave the machine, it stays on the machine.

The control that matches this model is deliberately boring: treat the payload as an egress file and inspect it before it becomes an HTTP body. The script below is a labeled proposal for a local tripwire, not a data-loss product, and it will miss encoded secrets so you keep reading. Save it as session_egress_scan.py and run it against the buffer you planned to send, not against the entire tree.

#!/usr/bin/env python3
"""Proposal: preflight scan for assistant prompts and leftover transcripts."""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

PATTERNS = [
    ("private_key_block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
    ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("generic_secret_assign", re.compile(
        r"(?i)\b(password|secret|token|api[_-]?key)\s*[:=]\s*\S{8,}"
    )),
    ("jwt_candidate", re.compile(
        r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"
    )),
    ("connection_string", re.compile(
        r"(?i)\b(postgres|mysql|mongodb(\+srv)?|redis)://[^\s'\"]+"
    )),
    ("private_ipv4", re.compile(
        r"\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"
        r"|\b192\.168\.\d{1,3}\.\d{1,3}\b"
        r"|\b172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}\b"
    )),
    ("internal_host", re.compile(r"(?i)\b[a-z0-9.-]+\.(internal|corp|lan|local)\b")),
    ("email_like", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I)),
]


def scan_text(text: str) -> list[dict]:
    hits: list[dict] = []
    for name, pattern in PATTERNS:
        for match in pattern.finditer(text):
            line = text.count("\n", 0, match.start()) + 1
            snippet = match.group(0)
            if len(snippet) > 48:
                snippet = snippet[:24] + "" + snippet[-8:]
            hits.append({"rule": name, "line": line, "preview": snippet})
    return hits


def redact_text(text: str) -> str:
    out = text
    for _name, pattern in PATTERNS:
        out = pattern.sub("[REDACTED]", out)
    return out


def iter_targets(path: Path) -> list[Path]:
    if path.is_file():
        return [path]
    files: list[Path] = []
    for child in path.rglob("*"):
        if child.is_file() and child.suffix.lower() in {".txt", ".md", ".log", ".json", ".jsonl"}:
            files.append(child)
    return files


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Scan a prompt or a transcript directory before you forget it exists"
    )
    parser.add_argument("path", type=Path)
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--write-redacted", type=Path, default=None)
    args = parser.parse_args()
    targets = iter_targets(args.path)
    report = []
    dirty = 0
    for file in targets:
        text = file.read_text(encoding="utf-8", errors="replace")
        hits = scan_text(text)
        report.append({"file": str(file), "hits": hits})
        if hits:
            dirty += 1
            if args.write_redacted is not None and file.is_file() and args.path.is_file():
                args.write_redacted.write_text(redact_text(text), encoding="utf-8")
    if args.json:
        print(json.dumps({"dirty_files": dirty, "results": report}, indent=2))
    else:
        for item in report:
            if not item["hits"]:
                print(f"clean: {item['file']}")
                continue
            print(f"residue: {item['file']} ({len(item['hits'])} hits)")
            for hit in item["hits"]:
                print(f"  L{hit['line']:>4}  {hit['rule']:<22}  {hit['preview']}")
    return 1 if dirty else 0


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

Run the checker twice, once against the outbound buffer and once against the places your editor actually writes. The second pass is the point of this article, because closing a panel is not the same as destroying a record. A clean git tree can still hide a dirty transcript in a cache directory you have not opened since last quarter.

python3 session_egress_scan.py /tmp/prompt.txt
python3 session_egress_scan.py /tmp/prompt.txt --write-redacted /tmp/prompt.redacted.txt
python3 session_egress_scan.py "$HOME/.cache" --json
echo $?
Enter fullscreen mode Exit fullscreen mode

Keep a fixture beside the script so the control does not rot into a comment that nobody runs. The sample below is labeled test input and uses obviously fake values on purpose, which is the only kind of secret that belongs in a public gist.

# testdata/session_residue.txt
POST /login failed for user ada@example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.faketail
DATABASE_URL=postgres://app:secret@db.prod.internal:5432/app
retry note: hop through 10.4.2.9 because the bastion timed out
Enter fullscreen mode Exit fullscreen mode
python3 session_egress_scan.py testdata/session_residue.txt
# expected: non-zero exit, hits on jwt, connection string, internal host, email, private ipv4
Enter fullscreen mode Exit fullscreen mode

If the exit code is non-zero, you do not bargain with the model about how sensitive the line “probably” is. You shrink the reproducing case until the scanner is quiet, then you send that smaller envelope and no other envelope. The model cannot unsee a secret you already shipped in round one, and neither can a log collector sitting behind that model. Treat a dirty scan like a failing test, not like a suggestion from a linter you plan to mute.

After the request leaves, you still have a local copy problem that git status will never mention in a standup. Chat panels, language-server logs, and HTTP debug dumps often keep the body of the last failing call for convenience. Search those paths on a laptop that has hosted an assistant for a week, and you will usually find a fragment you do not remember writing. That fragment is the shadow repository, and it deserves the same rotation story you already give to committed secrets.

# labeled examples — change roots to match your editor and operating system
rg -n "BEGIN (RSA |OPENSSH )?PRIVATE KEY|AKIA[0-9A-Z]{16}|postgres://" \
  "$HOME/Library/Logs" "$HOME/.cache" "$HOME/.local/share" 2>/dev/null
rg -n "Authorization: Bearer" . --glob '*.log'
python3 session_egress_scan.py "$HOME/.local/share" --json
Enter fullscreen mode Exit fullscreen mode

Treat a hit in a log the same way you treat a hit in history: rotate the credential, then shorten retention on the file that stored the photocopy. Deleting the visible thread is not the same as deleting the backing store, especially when crash reports zip the last request for “support quality.” If your team cannot answer where transcripts live overnight, you do not have a privacy review. You have a hope wearing a checklist costume.

Free remote endpoints do not dissolve those hops, even when they are convenient for a laptop that should not host a GPU. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which can stand in for a self-hosted inference box during a narrow experiment. The threat model does not change when the far side is free: the prompt still leaves your process, and the server still sits outside the repository access list. Scan locally, send the redacted working set, and then grep your own logs for the strings you thought you had deleted.

This scanner is a tripwire, not a classifier and not a substitute for a reviewed data-loss program with owners and tickets. It will miss secrets in screenshots, minified bundles, chunked pastes, and strings that were base64-wrapped twice by a well-meaning helper. It will also flag emails and internal hostnames that you truly need, which is useful if you then write one sentence explaining the exception. False positives are cheaper than a production key that now lives in somebody else’s abuse queue.

Do not use this workflow as permission to send regulated data into any remote model, including a free one you spun up for a demo. If you handle health records, payment data, unpublished vulnerability notes, or customer exports under contract, keep those sessions on systems counsel already approved in writing. Do not launder a secret by redacting the variable name while leaving the value in a nearby stack frame. A clean scan is not evidence that a remote operator will delete logs on your preferred calendar.

The people who benefit are developers who already paste stack traces into assistants and want a repeatable pause before the network call. The people who should skip it are teams with no remote models, or teams that already broker every prompt through a reviewed gateway with retention rules. If you are in the first group, draw the four hops on paper, run the outbound file and the cache directory through the script, and only then ask the model to explain the failure. Send the smallest redacted fixture that still fails, then search the leftover record as if it were another clone of the repo.

Top comments (0)