DEV Community

jaryn
jaryn

Posted on

Audit a Prompt Envelope Before CI Echoes the Match

I still run the same drill before I let a log near a model. Staging migrate fails. The trace is short enough to paste, and one line is a database URL with a password sitting in it.

Would I notice that on a tired scroll? Sometimes. Would I notice it after the model already answered? Almost never. So the drill starts with a pause, not with a prompt.

I write the candidate text to a file on the laptop. The chat box stays empty. That empty box is the point.

Name the zones before you name the bug

A model call is not a local function. It is egress.

Who receives the bytes? The endpoint you called. Who might retain them? That provider, plus any attached server the model can use to read a repo or run a tool. Who else keeps a copy without being asked? Shell history, CI logs, and the teammate who re-runs your debug script with set -x still on.

I keep four zones written down. Laptop session, model endpoint, attached server, artifact store. If I cannot name the zone for a field, the field does not leave. A nameless field is how a quick paste becomes an incident.

laptop session
  |  envelope.txt          review here, before any paste
  |  no-echo gate          deny log gets rule id + offset only
  |
  +-- do not cross raw --> model endpoint
  |
  +-- do not cross raw --> attached server (disk, process list, logs)
  |
  +-- copies you forgot -> artifact store (CI, history, support bundles)
Enter fullscreen mode Exit fullscreen mode

Does a free endpoint move those lines? No. Price is not a trust boundary. A zero invoice does not pull a remote process back inside your laptop.

A threat model, not a vibe check

I am not claiming a vulnerability in a vendor, and I am not publishing a finding. This is the invariant I want even when the log looks boring. Label the assets, then decide what a model is allowed to see.

Asset Zone If it leaves
Database URL with a password laptop only replay against whatever host the URL names
Bearer or session token laptop only the prompt holder holds the session
Private key block laptop only long-lived identity leaves with the paste
Customer identifier in a trace laptop only until minimized privacy incident, not just a leaked password
Internal hostname laptop only by default a map of the network, even with no password
SQLSTATE, error class, revision sha model-allowed high debugging value, low secret value

What about a stack trace? Useful, and filthy. Paths leak usernames. db.statement attributes leak query text. I strip both before I even run the gate, because the gate is a tripwire, not a parser for every telemetry SDK on earth.

Recovery is not a request to forget. If the bytes crossed, rotate the credential. Then ask what the endpoint and the server actually retain. A delete button on a transcript is a hope, and hopes are not controls.

What I refuse to send

I will not send connection strings. I will not send Authorization lines, cloud access-key ids, or a private-key banner. I will not send a raw span attribute map. That map is where http.request.header goes to hide.

I also will not send a file whose redaction tool printed the match into stdout. Think about that failure for a second. You blocked the model, then your CI log became the new envelope. Did you reduce exposure, or did you just change the sink?

Synthetic error text is fine. A SQLSTATE is fine. A revision sha is usually fine. A customer email is not, even when the explanation would land faster.

Faster is not the invariant I am enforcing. The model can be clever and still be the wrong recipient. Clever does not pull a field back across a zone line.

The no-echo fixture

What follows is an unexecuted template. I am not reporting a run against a live provider, and a local exit code would not prove an endpoint is safe. Target CPython 3.11 or newer. Standard library only, because I do not want a dependency install standing between me and a deny decision.

Positive fixture, fixtures/ok_trace.txt. This one should be allowed.

migrate failed
sqlstate=42P01
relation=invoices
revision=abc123
service=billing
Enter fullscreen mode Exit fullscreen mode

Negative fixture, fixtures/bad_trace.txt. Both secrets are placeholders. Do not improve the test with a real one.

migrate failed
DATABASE_URL=postgres://app:example-not-a-real-secret@db.internal:5432/billing
password=example-not-a-real-secret
sqlstate=42P01
Enter fullscreen mode Exit fullscreen mode

audit_prompt_envelope.py:

#!/usr/bin/env python3
"""UNEXECUTED TEMPLATE. Fail closed. Never print the matched secret."""

import re
import sys
from pathlib import Path

RULES = (
    ("conn_url", re.compile(r"(?i)\b(?:postgres|mysql|mongodb|redis|amqp)://\S+:\S+@")),
    ("aws_access_key_id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("private_key", re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----")),
    ("bearer", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-]{8,}")),
    ("assignment", re.compile(r"(?i)\b(?:api[_-]?key|secret|password|token)\b\s*[:=]\s*\S+")),
)

def audit(text: str) -> list[tuple[str, int]]:
    hits = []
    for rule_id, pattern in RULES:
        for match in pattern.finditer(text):
            hits.append((rule_id, match.start()))
    return hits

def main() -> int:
    path = Path(sys.argv[1])
    hits = audit(path.read_text(encoding="utf-8", errors="replace"))
    if not hits:
        print(f"allow path={path} rules=0")
        return 0
    for rule_id, offset in hits:
        print(f"deny rule={rule_id} offset={offset}")
    return 2

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

If you execute this later, the evidence you want is dull on purpose. Rule id, byte offset, and exit status. Not the URL. Never the URL.

# UNEXECUTED TEMPLATE. Intended contract, not an attached run.
python3 audit_prompt_envelope.py fixtures/ok_trace.txt
echo "exit=$?"
# intended: allow path=fixtures/ok_trace.txt rules=0
# intended: exit=0

python3 audit_prompt_envelope.py fixtures/bad_trace.txt
echo "exit=$?"
# intended: deny rule=conn_url offset=<n>
# intended: deny rule=assignment offset=<n>
# intended: exit=2
Enter fullscreen mode Exit fullscreen mode

The allow path should print allow and exit 0. The deny path should print two deny lines, one for conn_url and one for assignment, then exit 2. I am describing the template's intended contract, not a run I am attaching as proof.

Why offset instead of the match? Because the next tool in the pipeline will archive stdout. Give it nothing it can reuse. If your CI plugin dumps the input file on failure, turn that dump off, or you rebuilt the leak with better intentions.

How I run the gate

None of these steps start in the chat box. If they do, you already crossed.

  1. Write the candidate prompt to envelope.txt on the laptop. Do not paste it into a browser just to see. Pasting is the egress. The file is the review surface.
  2. Run the template on fixtures/ok_trace.txt and require exit 0. A broken script that your shell ignores is fail-open. Look at $?. If the positive fixture fails, fix the gate before you trust a deny.
  3. Run the template on envelope.txt. Exit 2 means stop. Exit 0 means these rules did not match. That is not a cleanliness certificate. Encoded secrets, split lines, and binary attachments walk right past this list.
  4. Replace denied fields with placeholders such as <DB_URL> and <TOKEN>. Point at the local secret store in a note the model does not need. Do not write a line that says to ignore the secret above. You would already have sent it.
  5. Re-run the gate on the rewritten file. Only an allow moves forward. Skipping this because you are tired? That is the leak window.
  6. Send the minimized envelope, and only if you still need a model. Keep the original file off any attached server. If a tool runner wants a worktree, give it a scrubbed copy. Do not mount $HOME. Home directories collect .env files, cloud CLIs, and notes you forgot were secrets.
  7. After the call, review history without reprinting lines. If a count is non-zero, rotate the credential, then chase retention. Detection without rotation is a diary entry.
# UNEXECUTED TEMPLATE. Count only, so this review does not echo a line.
# Point HISTFILE at your shell history first.
grep -cE 'postgres://|AKIA|BEGIN .*PRIVATE KEY' "$HISTFILE" || true
Enter fullscreen mode Exit fullscreen mode

Where does this want to live? Step 3 belongs in CI for any job that uploads a prompt artifact. Step 6 belongs on the runner, because a hurried human will skip the script. Two layers, one invariant: credential-shaped strings do not cross, and the deny log does not become a second copy.

Where free capacity sits

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I am using only two product facts in this walkthrough. MonkeyCode has free model access, and it has a free server option. I am not naming models, token quotas, hardware, duration, or benchmarks. I have not pinned those here.

Read the current docs before you plan around either fact. Availability language goes stale. A blog post is a bad place to freeze it.

How do those two facts sit on the four zones? Free model access is still the model endpoint. The bytes leave your laptop. A free server is still an attached server: disk, process list, logs, and whatever debug bundle a support path might collect.

Free does not mean inside your VPC. It does not mean the logs are yours. It does not mean another tenant cannot meet a file you left in a shared workspace.

Would I point a raw deploy log at that server because the offer is free? No. I would point the scrubbed worktree from step 6 at it, and only after I have read whatever the project currently says about retention and tenancy. If those pages are silent, I assume retention I cannot bound, and I send less.

If you want a non-production place to exercise the minimized fixture, check MonkeyCode's current free model access and free server option, confirm retention in the docs you have today, and only then send an envelope that already passed the no-echo gate. That is the only invitation I am making. The gate still comes first.

Prevent, detect, recover

Phase Control Failure you should expect
Prevent No-echo gate in CI, exit 2 on a rule hit A wrapper that prints the source file on failure
Prevent Scrubbed worktree, no $HOME mount Tool runner inherits the developer environment
Detect Offset-only deny lines, plus a count-only history check set -x echoing a header you thought was local
Recover Rotate, then chase retention Deleting a chat and calling the incident closed

Regex misses base64, multiline splits, and the HAR file you attached because the UI made it easy. The assignment rule will also false-positive on the word token in ordinary prose, so tune it before you fail a pipeline on it. I am not describing a DLP product. I am describing a tripwire for shapes that show up in deploy logs.

If your only control is this file, you are not done. You have a regression test for one invariant. Treat a green exit as a narrow pass, not a security review.

Who should walk away

Do not use redact-and-send on production customer data, payment data, or anything under a retention duty you cannot waive. Minimizing is not a lawful basis. If the right action is do not send, an allow exit is irrelevant. Ignore it.

Do not treat a free server as an air gap. If policy says prompts stay on-box, a remote model is out. Full stop. Do not cite this template as evidence that a product is safe, or that a vulnerability exists. It does not scan a vendor. It scans a file you chose.

If your team needs a contractual deletion window, a named region, and a reviewed subprocessor list, stop at the docs. A tutorial gate does not finish that review. It does not even start it.

The question I would put in the retro

Which check belongs in CI, and which layer has to enforce it when CI is not on the path? I ask that in the retro, not in the prompt.

The no-echo deny belongs in CI. Artifact uploads are quiet, and they last. The rule against mounting home belongs on the runner, because that is where the extra files are. The model is a bad enforcement point for both.

By the time the model can see the string, the boundary is already behind you. So I audit the envelope before the call. I refuse to let the auditor reprint the match. And I treat free capacity as capacity, not as trust.

Top comments (0)