A checkout 500 should have stayed a checkout 500.
Here is the sequence I use in reviews. It is a composite, not a customer postmortem, and I am not claiming a live finding. An on-call engineer exports the ticket, runs kubectl logs deploy/checkout --tail=200, and drops the last Terraform plan into ask.md. They hand that file to a coding assistant and ask why the payment call failed.
The answer comes back useful. The file does not. It still holds a customer email, a session-cookie fragment, and a Postgres DSN that survived the stack frame. Did anyone intend to send that? No. Did the trust boundary notice intent? Also no.
The invariant I want is smaller than a policy PDF. A prompt bundle either passes a deny fixture, or it does not leave the workstation.
Name the boundary before you name the model
I split the path into four zones. Your editor is zone 0. Ticket text and cluster logs are zone 1: still inside the company, still full of other people's data. A coding server you operate is zone 2. A remote model, including a free model path you do not host, is zone 3.
Zone 2 feels safe because you can see the disk. It is not automatically safe. If that server still forwards prompts, zone 3 is the real boundary. If the server logs full prompts, your own disk becomes a second copy of the secret.
Which copy do you rotate first? And who else can read zone 2 when you are asleep?
ticket/logs (zone 1) -> ask.md (zone 0)
| deny fixture must fail closed here
v
coding server you operate (zone 2)
| if it proxies outbound
v
remote model (zone 3)
Free hosting does not move those arrows. It only changes who pays for the box.
What I will not put in the bundle
I keep a short refuse list. If a line matches, the bundle is dead. I do not "just this once" it.
- Connection strings, cloud access-key shapes, kubeconfig
client-key-data, and private key blocks. - Session cookies,
Authorizationvalues, and one-time reset links, even when truncated. - Customer identifiers that are not already public: email, phone, account id, raw ticket body.
- Production Terraform plans, database dumps, and
kubectloutput you have not scrubbed. - Another team's incident-channel paste. That is not your data to donate to a model.
A sanitized stack trace with file paths and line numbers? Fine. A failing unit test you wrote with fake data? Fine. The raw ticket? Not fine.
Would you paste that same file into a public gist? If the answer is no, a remote model is the wrong destination too.
Step 1. Pin the fixture, and label it unexecuted
This is an unexecuted template. I have not run it against a production corpus, and a green local run would not prove your logs are clean. Pin the interpreter. I wrote it for Python 3.12 stdlib only, so you are not pulling a scanner package you have not reviewed.
Save it as prompt_egress_gate.py.
#!/usr/bin/env python3
"""Unexecuted template. Fail closed before a prompt bundle leaves zone 0.
Target: Python 3.12 stdlib. Not a DLP product. Not evidence of a live leak.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
RULES: list[tuple[str, re.Pattern[str]]] = [
("private_key_block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("postgres_dsn", re.compile(r"postgres(?:ql)?://[^\s:]+:[^\s@]+@", re.I)),
("aws_access_key_shape", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("bearer_header", re.compile(r"(?i)authorization:\s*bearer\s+\S+")),
("cookie_header", re.compile(r"(?i)\bcookie:\s*\S+=")),
("kube_client_key", re.compile(r"(?i)client-key-data:\s*\S+")),
("generic_secret_assign", re.compile(
r"(?i)\b(?:password|secret|token)\s*[:=]\s*\S+"
)),
("email_address", re.compile(
r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I
)),
]
def scan(text: str) -> list[str]:
hits: list[str] = []
for rule_id, pattern in RULES:
if pattern.search(text):
hits.append(rule_id)
return hits
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: prompt_egress_gate.py <bundle>", file=sys.stderr)
return 2
path = Path(argv[1])
hits = scan(path.read_text(encoding="utf-8", errors="replace"))
if hits:
print(f"DENY {path} rules={','.join(hits)}")
return 2
print(f"ALLOW {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Why fail on email? Because a support bundle without one is usually still debuggable, and a bundle with one is already customer data. Too blunt for a product DLP. Right bluntness for a lab gate.
You can delete that rule. Do not delete the DSN rule.
Step 2. Add a negative fixture that must fail
Negative means "this must not be sent." Use obvious fakes. Do not paste a real key just to see if the regex wakes up.
fixtures/negative_ticket.md:
Ticket 18422
Customer: alex@example.com
Stack: dial tcp: postgres://app:example-not-a-secret@db.internal:5432/app
Access key shape: AKIAEXAMPLEKEY000000
Request header: Authorization: Bearer example.not.a.real.token
Cookie: session=example-cookie-fragment
Expected evidence, once you actually run it:
python3.12 prompt_egress_gate.py fixtures/negative_ticket.md
echo "exit=$?"
I expect exit code 2 and a DENY line naming email_address, postgres_dsn, aws_access_key_shape, bearer_header, and cookie_header. If you get ALLOW, the fixture is wrong. Stop. Do not send the file while you "fix the regex later."
Step 3. Add a positive fixture that must pass
Positive means "this is the shape I am willing to send." No identifiers. No headers. A reconstructed failure only.
fixtures/positive_checkout.md:
Symptom: checkout returns 500 after the payment client times out.
Repro: unit test TestCheckoutTimeout uses host "payments.invalid" and cart id "cart_test_1".
Question: which retry belongs in the client, and which belongs in the worker?
Constraint: point at the interface. Do not propose a new store for credentials.
python3.12 prompt_egress_gate.py fixtures/positive_checkout.md
echo "exit=$?"
I expect exit code 0 and ALLOW. If the positive fixture trips generic_secret_assign, your wording is too close to an assignment. Rewrite the question. Do not weaken the rule to make the demo green.
Want a one-liner that fails the build when someone adds a third file? This wrapper is also an unexecuted template.
#!/bin/sh
# prompt-egress-check.sh — unexecuted template, Python 3.12
set -eu
root=${1:-fixtures}
fail=0
for bundle in "$root"/*.md; do
python3.12 prompt_egress_gate.py "$bundle" || fail=1
done
exit "$fail"
Point it at a directory of synthetic bundles. Do not point it at ~/Downloads full of real tickets and call that a test.
Step 4. Decide the destination before you pick a server
This is the matrix I want in the review, not in a slide.
| Data class | Local editor | Server you operate, no remote model | Remote model, including a free path |
|---|---|---|---|
| Synthetic failing test | Yes | Yes | Yes, after the gate |
| Scrubbed stack trace, no ids | Yes | Yes | Only if the gate is clean |
Raw ticket or kubectl dump |
Yes, on your laptop | No, unless the host is approved for that data | No |
| DSN, cookie, key, kubeconfig | Secret store only | No | No |
A free server is still a server. Who else has SSH? Where do request bodies go? Is the prompt written to /var/log on every call? If you cannot answer those three, the host is not zone 2. It is an unreviewed copy of zone 3.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The walkthrough uses that open-source coding platform as the lab example, not as proof that any host is safe.
The operator describes a free model path and a free server option. I am not stating model names, token quotas, hardware, or how long that offer lasts. A chat may quote a large free allowance. Read the current project page, and treat any secondhand number as stale.
If you stand up that server, put the gate in front of it. Do not aim it at production logs to see whether the model is clever.
Step 5. Prevent, detect, recover
| Phase | Control | Evidence I want |
|---|---|---|
| Prevent | Run the gate on ask.md before any client upload. Exit 2 blocks the send. |
Shell trace with DENY and rule ids |
| Detect | Alert if the coding server logs a request body, or if egress from zone 2 is not on an allowlist. | A log sample that shows bodies are dropped, plus one denied egress record |
| Recover | Rotate anything that already crossed. Rewrite from the positive fixture. Do not edit the leaked file in place and resend it. | A rotation ticket, not a hope that the vendor skips training |
Training-opt-out language is not a recovery plan. If the DSN left, rotate the password. If the cookie left, revoke the session. If the email left, follow your incident path.
I would rather rotate a canary than argue about retention. Would a model vendor's policy page undo a password that already crossed? No.
What this gate will miss
Regex does not understand base64, gzip, or a screenshot of the same ticket. It will miss a secret split across two lines. It will also flag a design note that says token: and annoy you.
Good. Annoyance is cheaper than a leaked DSN.
Do not encode the bundle to slip past your own rule. That is how a lab gate becomes theater. And do not treat ALLOW as a legal review.
This is the wrong tool if you handle health data, payment card numbers, or a contract that bans third-party processors. A Python file on a laptop is not a control framework.
Who should skip the approach? Teams that already enforce egress DLP and a model gateway. Use that. Also skip it if your free server is a shared host whose admin you cannot name. You do not have a trust boundary then. You have a roommate.
One lab path, then stop
I keep the corpus synthetic: the two fixtures above, nothing pulled from the ticket system. A free model path is enough to check whether your client respects a failed gate. A free server is enough to check whether you can disable prompt logging and constrain egress.
Confirm both on the current project page before you write them into a runbook. Quotas and hardware change. A number you remember from a chat is not a control.
Which invariant belongs in CI, the deny fixture on every ask.md and ticket export? And which layer should enforce it: the workstation, the server you operate, or the egress proxy in front of the model? If you can only fund one, fund the layer that still runs when someone is tired. That is rarely the model.
Top comments (0)