DEV Community

jaryn
jaryn

Posted on

Your Free Model Endpoint Keeps Logs — Build the Relay That Decides What They Say

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

  1. The agent reads a file containing DATABASE_URL=postgres://app:hunter2@db.internal:5432/app.
  2. It posts the file to the relay.
  3. The relay writes the request body to disk.
  4. The relay forwards it to the model endpoint.
  5. 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()
Enter fullscreen mode Exit fullscreen mode

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')")"
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Bind loopback only. umask 077 then python3 relay.py --bind 127.0.0.1 --port 8088 2>> relay.log &. 0.0.0.0 on a host you don't own is a port scan waiting to happen.
  3. Reach it over a tunnel, not a public port: ssh -N -L 8088:127.0.0.1:8088 you@host and point the agent at http://127.0.0.1:8088/relay.
  4. 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.
  5. 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.
  6. 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)