A stack trace leaves someone's editor. It carries a database URL with a password, because connection strings love stack traces. It hits a small relay on a free server, gets forwarded to a free model endpoint, and comes back as a fixed bug.
Fine outcome. Now grep the relay's own log.
The plaintext is sitting there — in the request body someone logged "for debugging", and in the response someone logged "for observability". The provider's copy is outside your control. Your copy is not. That asymmetry is the whole article.
The failing sequence, in five lines
- The agent reads a file containing
DATABASE_URL=postgres://app:hunter2@db.internal:5432/app. - It posts the file to the relay.
- The relay writes the request body to disk.
- The relay forwards it to the model endpoint.
- Six weeks later, a grep finds it.
Which line would survive a review? Line 3 — and only line 3. Lines 1 and 4 are policy questions you can't unilaterally answer. Line 3 is a code change.
Draw the boundary map before you write the code
You can't redact what you haven't located. So name the boundaries first.
| # | Boundary | What crosses | Who can read it later |
|---|---|---|---|
| B1 | workspace → relay | full prompt, unsanitized | your shell history, editor backups, agent transcripts |
| B2 | relay → model endpoint | whatever you choose to forward | the provider's retention policy, your TLS terminator |
| B3 | relay → its own log/disk | whatever you chose to log | anyone with disk access — on an ephemeral free host, assume "anyone" |
| B4 | model response → workspace | model output | you, plus every tool that persists it |
B3 is the only boundary where you own both the writer and the reader. That's where the artifact goes.
One design rule falls out of this: the relay must be one-way. No rehydration map, no "restore original values before forwarding". A bidirectional relay is a second copy of the secret, on the host you trust least.
Classify your data, then pick the action
Three actions, not two. "Send or don't send" is a false binary that pushes people back to pasting the whole repo.
| Data class | Action | Why |
|---|---|---|
| Private keys, cloud access keys, bearer tokens, connection strings with passwords | Block | Rotation is the only real remedy; the model never needs the literal value |
| Customer email, internal hostnames, private IPs, ticket IDs | Placeholder | The model usually needs the shape, not the value |
| Source code with no matching pattern, public stack traces, public docs | Forward raw | Cost of redaction outweighs the marginal risk |
| Regulated data (health, cardholder, government IDs) | Stop | Redaction shrinks blast radius; it does not create a lawful basis |
Write that table down before you deploy. Two columns in a file beats a paragraph in a wiki nobody opens.
Build the relay: 60 lines, standard library only
No dependencies, no model SDK, no framework. The relay does not call the model itself — it returns a decision, and your agent only forwards what comes back marked forward.
#!/usr/bin/env python3
"""relay.py - one-way redaction relay. Python 3.8+, standard library only."""
import argparse, hashlib, json, re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
RULES = [
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----")),
("cloud_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("bearer", re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{20,}")),
("conn_string", re.compile(r"\b[a-z][a-z0-9+]*://[^\s:@/]+:[^\s@/]+@[^\s]+")),
("email", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")),
("private_ip", re.compile(r"\b(?:10\.\d{1,3}|127\.\d{1,3}|192\.168)\.\d{1,3}\.\d{1,3}\b")),
]
BLOCK = {"private_key", "cloud_key", "bearer", "conn_string"}
MAX_BODY = 64 * 1024
def redact(text):
counts, out = {}, text
for name, rx in RULES:
def rep(m, _n=name):
counts[_n] = counts.get(_n, 0) + 1
return f"<<{_n}:{counts[_n]}>>"
out = rx.sub(rep, out)
return out, counts
def decide(text):
redacted, counts = redact(text)
blocking = sorted(set(counts) & BLOCK)
return {
"action": "blocked" if blocking else "forward",
"blocking": blocking,
"classes": counts,
"prompt": redacted,
"body_sha256": hashlib.sha256(text.encode()).hexdigest(),
"bytes_in": len(text.encode()),
}
class Relay(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != "/relay":
return self.reply(404, {"error": "not found"})
n = int(self.headers.get("content-length", 0))
if n > MAX_BODY:
return self.reply(413, {"error": "too large"})
try:
text = json.loads(self.rfile.read(n) or b"{}")["prompt"]
assert isinstance(text, str)
except Exception:
return self.reply(400, {"error": "bad payload"})
d = decide(text)
# audit line: no body, no redacted body, no match values - hashes only
self.log_message("%s", json.dumps(
{k: d[k] for k in ("action", "classes", "blocking", "body_sha256", "bytes_in")},
sort_keys=True))
if d["action"] == "blocked":
return self.reply(422, {"action": "blocked", "blocking": d["blocking"], "classes": d["classes"]})
return self.reply(200, {"action": "forward", "prompt": d["prompt"],
"classes": d["classes"], "body_sha256": d["body_sha256"]})
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--bind", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8088)
a = ap.parse_args()
ThreadingHTTPServer((a.bind, a.port), Relay).serve_forever()
Look at what never gets written to disk: the original body, the matched substring, the placeholder count. Only a class name, a count, and a SHA-256 of the input. If the log leaks, it leaks a fingerprint — not a credential.
Fixtures: prove it blocks, and prove it doesn't
A relay that blocks everything passes every "did it block?" test and is useless. You need negative fixtures too — inputs that must come back 200 untouched.
#!/usr/bin/env bash
# fixtures.sh - run against a relay on 127.0.0.1:8088
set -u
RELAY=http://127.0.0.1:8088/relay
run() {
local name=$1 want=$2 payload=$3 got
got=$(curl -s -o /tmp/relay.out -w '%{http_code}' \
-H 'content-type: application/json' -d "$payload" "$RELAY")
printf '%-16s want=%s got=%s %s\n' "$name" "$want" "$got" "$(head -c 150 /tmp/relay.out)"
}
run negative-01 200 '{"prompt":"path=/srv/app/.env host=example.com docs=https://203.0.113.10/help"}'
run blocked-aws 422 '{"prompt":"key=AKIAIOSFODNN7EXAMPLE"}'
run blocked-conn 422 '{"prompt":"DATABASE_URL=postgres://app:hunter2@db.internal:5432/app"}'
run redact-email 200 '{"prompt":"customer jane.doe@example.com reports 500 on /checkout"}'
run oversize 413 "$(printf '{"prompt":"%s"}' "$(head -c 70000 /dev/zero | tr '\0' 'a')")"
Expected evidence, line by line — this is the part people skip, and then they can't tell a working relay from a silent one:
| Fixture | Want | Evidence you should see |
|---|---|---|
negative-01 |
200 |
"classes": {} and the prompt echoed unchanged |
blocked-aws |
422 |
"blocking": ["cloud_key"], no prompt echoed back |
blocked-conn |
422 | "blocking": ["conn_string"] |
redact-email |
200 |
jane.doe@example.com replaced by <<email:1>>
|
oversize |
413 |
{"error": "too large"} and no log line with body content |
AKIAIOSFODNN7EXAMPLE is AWS's own documentation placeholder — deliberately shaped like a key, not a live one. Never fixture with a real credential; a fixture file is a credential file with worse permissions.
Standing it up on a free server
Here's where a free server option earns its place: the relay needs compute and zero secrets. That's the ideal tenant for a host whose lifecycle you don't control.
MonkeyCode currently offers free model access and a free server option — the operator advertises a token allowance (10M tokens at the time of writing) and states the free tier is available to try. Verify the current numbers on the site before you plan around them, because free-tier terms are the least stable component in this design.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Numbered steps:
-
Give the host nothing to steal. No env vars, no
.env, no SSH keys beyond the one you need. The relay has no credentials by construction — keep it that way. -
Bind loopback only.
umask 077thenpython3 relay.py --bind 127.0.0.1 --port 8088 2>> relay.log &.0.0.0.0on a host you don't own is a port scan waiting to happen. -
Reach it over a tunnel, not a public port:
ssh -N -L 8088:127.0.0.1:8088 you@hostand point the agent athttp://127.0.0.1:8088/relay. - Run the fixtures twice — once on the host, once through the tunnel. Identical output, or you have two relays and only one of them is yours.
- Make the relay unskippable. An agent that can reach the model endpoint directly will eventually do so. Default-deny egress so the only reachable destination is the relay; otherwise you built a suggestion, not a boundary.
- Treat the log as a cache, not a record. No body content, hash lines only, rotate or truncate freely. If you need incident review, ship the hash lines off-host — not the bodies.
Prevent, detect, recover
| Layer | Control | Fails how |
|---|---|---|
| Prevent | Relay returns blocked for credential classes |
Misses secrets that match no pattern |
| Detect | Hash-only log line per request; diff classes over time |
Can't tell you what leaked, only that something matched |
| Recover | Rotate on any blocked event that also shows a provider response |
Rotation after the fact can't undo an already-forwarded copy |
That third row is the honest one. If a blocked fixture ever appears alongside a 200 response from a model in the same minute, assume the request got out through a second path and rotate. Don't debate it.
What this does not do
Regex is not a classifier. A UUID-shaped API token, a base64 blob, a customer name in prose — none of these match a pattern, and no amount of [A-Za-z0-9]{32,} fixes it without burying you in false positives. The relay raises the cost of an accident. It does not make a free endpoint safe for regulated data.
It's also one-way by design, so it breaks any task that genuinely needs the literal value — "why does this exact token fail to parse?" Use a synthetic fixture with the same shape instead of the real value. And it says nothing about provider retention; that boundary is theirs.
Who should not use this: teams that need contractual data-processing terms, a reproducible SLA, or an audit trail admissible in a compliance review. A redaction layer in front of a free tier is an engineering control against accidents, not a compliance posture. If losing a credential for an hour would page three people, fix rotation speed before you optimize the regex.
Start with the negative fixture, not your real repository. A relay that hasn't been proven to pass clean code through is just a firewall with good marketing.
And the question I'd leave on the table: which of these rules belongs in CI, and which belongs in the network layer? My answer — the fixtures belong in CI (they're fast, deterministic, and catch every rule regression), and the egress default-deny belongs in the network (it's the only layer an agent can't argue with). The relay is the thing in the middle, and the middle is where you keep the log you can afford to lose.
Top comments (0)