DEV Community

Riley Lin
Riley Lin

Posted on

The Prompt Is an Export License

Every remote coding prompt is a data export, and you should classify it before it leaves your machine. You would not attach an unreviewed archive to an external ticket, yet many of us paste whole files into a model. The model cannot unsee a secret, and neither can the logs that sit behind that endpoint. Treat the request as a customs form, then decide what is allowed to cross the boundary.

This walkthrough is about that classification step, not a beauty contest among coding assistants. You will run a small local gate that labels regions of a proposed prompt as public, internal, or forbidden. The gate never needs network access, which is the reason it sits in front of any remote call. Convenience on the far side of the wire does not change the label on your side.

You can point the same gate at any remote assistant, including MonkeyCode, after the payload is clean. Disclosure: This article was prepared as part of MonkeyCode's product outreach, and it does not invent lab numbers or model charts. Free model access and a free server option do not move the trust boundary onto your laptop. They only make a remote call cheaper to start, so the export license still has to be issued locally.

Think of the prompt as a shipping manifest with three columns that you fill before anyone else sees the crate. The first column is public material, such as an error string you could post on a forum without naming a tenant. The second column is internal texture, such as a hostname that slowly draws a floor plan for anyone who collects enough samples. The third column is forbidden cargo, such as a bearer token that turns a model provider into an accidental keyholder.

A useful analogy is airport security for source, because the scanner is allowed to be crude if it stands next to you. You are not building a compiler for secrets, and you should not pretend a regex is a clearance program. You are building a pause: a chance to refuse the flight when a private key is sitting in the carry-on. The pause is the control, and the pattern list is only a flashlight you keep on your own desk.

Here is a compact decision matrix you can keep beside that flashlight. Public API shapes, documented status codes, and stack frames with dummy names may leave when they hold no tenant data. Internal hostnames, RFC1918 addresses, and work emails should become stable placeholders so a transcript cannot be stitched into a network map. Forbidden items include private keys, cloud access keys, connection strings, raw Authorization headers, and any credential that was live even inside a test fixture. When a file mixes those classes, you split the file; you do not average the risk and hope the model is polite.

Class Typical region Crosses the wire? Gate action
public documented errors, public types yes copy through
internal private DNS, RFC1918, work email only as a placeholder substitute
forbidden keys, bearer headers, database URIs no drop the line and fail

Before you run the gate, stage a tiny folder that contains only the files you intended to quote. Copy them into /tmp/export-src so the scanner cannot wander into your home directory by accident. Drop a decoy secret in one file so you can see a forbidden hit on purpose. The decoy tests the gate, not the model, and it must never be a real key from a living environment.

mkdir -p /tmp/export-src
cat > /tmp/export-src/app.py << 'EOF'
# public: a handler you might show in a gist
def health():
    return {"ok": True}

# internal texture that maps a floor
DB_HOST = "payments.prod.internal"
DB_ADDR = "10.4.12.9"

# forbidden cargo, AWS documented example key only
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AUTH = "Authorization: Bearer eyJhbGciOiFAKE"
EOF

cat > /tmp/export-src/errors.md << 'EOF'
Timeout talking to https://api.example.com/v1/health
Contact jane.doe@example.com if this is a drill.
EOF
Enter fullscreen mode Exit fullscreen mode

Save the gate as prompt_export_gate.py and keep it offline. It reads named files, writes a redacted bundle, and writes a receipt that stores line digests instead of the secret text. Directories are refused on purpose, because a recursive walk is how a helpful tool becomes an export of the whole tree.

#!/usr/bin/env python3
"""Local export gate for coding-assistant prompts. No network I/O."""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

RULES = [
    ("forbidden", "pem_block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
    ("forbidden", "aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("forbidden", "generic_bearer", re.compile(r"(?i)\b(authorization:\s*bearer|x-api-key)\s+\S+")),
    ("forbidden", "named_secret", re.compile(r"(?i)\b(aws_secret_access_key|private_key|client_secret)\s*[:=]\s*\S+")),
    ("forbidden", "db_uri", re.compile(r"(?i)\b(postgres|mysql|mongodb(\+srv)?)://[^\s]+")),
    ("internal", "rfc1918", re.compile(
        r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|"
        r"172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3})\b"
    )),
    ("internal", "hostname_local", re.compile(r"\b(?:[a-z0-9-]+\.)+(?:internal|local|corp|lan)\b", re.I)),
    ("internal", "email", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I)),
]

@dataclass
class Finding:
    path: str
    line_no: int
    cls: str
    label: str
    digest: str

def line_digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:12]

def classify_line(line: str) -> list[tuple[str, str]]:
    return [(cls, label) for cls, label, rx in RULES if rx.search(line)]

def scan_path(path: Path) -> tuple[str, list[Finding]]:
    findings: list[Finding] = []
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        return f"# skip {path}: {exc}\n", findings

    out_lines: list[str] = []
    for i, line in enumerate(text.splitlines(), start=1):
        hits = classify_line(line)
        if any(cls == "forbidden" for cls, _ in hits):
            labels = ",".join(label for _, label in hits)
            findings.append(Finding(str(path), i, "forbidden", labels, line_digest(line)))
            out_lines.append(f"[REDACTED:{labels}]")
            continue
        redacted = line
        if hits:
            labels = ",".join(label for _, label in hits)
            findings.append(Finding(str(path), i, "internal", labels, line_digest(line)))
            for cls, label, rx in RULES:
                if cls == "internal":
                    redacted = rx.sub(f"[REDACTED:{label}]", redacted)
        out_lines.append(redacted)
    return f"\n# --- {path} ---\n" + "\n".join(out_lines) + "\n", findings

def main() -> int:
    parser = argparse.ArgumentParser(description="Classify a prompt export locally.")
    parser.add_argument("paths", nargs="+", type=Path)
    parser.add_argument("--receipt", type=Path, default=Path("export-receipt.json"))
    parser.add_argument("--bundle", type=Path, default=Path("export-bundle.txt"))
    parser.add_argument("--fail-on", choices=["forbidden", "internal"], default="forbidden")
    args = parser.parse_args()

    bundle: list[str] = []
    findings: list[Finding] = []
    for path in args.paths:
        if path.is_dir():
            print(f"refuse directory: {path}", file=sys.stderr)
            return 2
        chunk, found = scan_path(path)
        bundle.append(chunk)
        findings.extend(found)

    args.bundle.write_text("".join(bundle), encoding="utf-8")
    receipt = {
        "files": [str(path) for path in args.paths],
        "findings": [asdict(item) for item in findings],
        "forbidden": sum(1 for item in findings if item.cls == "forbidden"),
        "internal": sum(1 for item in findings if item.cls == "internal"),
    }
    args.receipt.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
    print(f"wrote {args.bundle} and {args.receipt}")

    blocked = receipt["forbidden"]
    if args.fail_on == "internal":
        blocked = blocked or receipt["internal"]
    if blocked:
        print("export blocked: classified regions still present", file=sys.stderr)
        return 2
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it against the staged files and read the exit code before you open a chat box. A non-zero status means you do not paste, even if the bundle looks readable at a glance. The receipt is the license stub, and the bundle is only cargo that survived the scanner.

python3 prompt_export_gate.py /tmp/export-src/app.py /tmp/export-src/errors.md \
  --bundle /tmp/export-bundle.txt \
  --receipt /tmp/export-receipt.json \
  --fail-on forbidden
echo "gate_status=$?"
python3 - << 'PY'
import json
from pathlib import Path
receipt = json.loads(Path("/tmp/export-receipt.json").read_text())
print("forbidden", receipt["forbidden"], "internal", receipt["internal"])
for row in receipt["findings"]:
    print(row["cls"], row["label"], row["path"], "line", row["line_no"], "digest", row["digest"])
print(Path("/tmp/export-bundle.txt").read_text())
PY
Enter fullscreen mode Exit fullscreen mode

The receipt stores a digest of the line, not the line itself, because an audit log that reprints the password is just another envelope. You keep classes, paths, and line numbers so you can find the problem in the original tree. You do not keep a second copy of the secret in JSON next to the redacted bundle. If the receipt and the bundle ever disagree, you trust the refusal and you still do not paste.

You should also watch comments, because comments are where tickets, customer names, and reset links like to hide. A block that looks like documentation can still name a hostname that only exists on your side of the firewall. If you need the model to reason about control flow, you can keep the structure and replace the nouns. If you need the model to reason about a leaked key, you should rotate the key in your own systems instead of mailing it to a model for advice.

Logs deserve the same license, including the ones your editor writes while you think you are only chatting. Tool output, debug panes, and crash text often reconstruct environment variables that never appeared in the file you meant to share. If your assistant wraps a local command, you classify that stdout with the same gate before it becomes context. A free remote runtime does not make those bytes less sensitive; it only adds one more place they can rest.

This approach has sharp limits, and you should treat them as part of the method rather than fine print. Regular expressions will miss secrets that are split across lines, stored in images, or encoded in ways the flashlight does not know. They will also false-flag public sample keys, which is annoying and still cheaper than shipping a real one. The gate does not encrypt anything, does not negotiate a contract with a vendor, and does not prove that a provider will delete transcripts on your schedule.

You should not use this workflow as a substitute for a real data-loss program when you handle payment data, health records, or government material. You should not use it to launder a dump of production traffic into a prompt by redacting only the obvious header. You should not use it if your threat model includes an attacker on your laptop, because the original files remain on disk in the clear. In those cases you need isolation, retention rules, and people with authority, not a Python file in /tmp.

Used honestly, the gate gives you a repeatable pause between curiosity and transmission. You classify the crate, you keep a receipt that cannot replay the secret, and you only then decide whether a remote model is allowed to see the remainder. If you already reach for MonkeyCode's free model access or free server option, run this license step on the working files first and keep the receipt with your session notes.

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

Top comments (0)