Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

Before the First Prompt: A Data-Class Gate for Free-Tier Model Trials

The first prompt in that trial was supposed to be boring. Just "summarize the failing test." What actually left the laptop was 41 KB of pytest -vv output, and two of those lines contained a database URL with an inline pa

The first prompt in that trial was supposed to be boring. Just "summarize the failing test." What actually left the laptop was 41 KB of pytest -vv output, and two of those lines contained a database URL with an inline password. Nobody wrote a policy that allowed that. Nobody wrote a policy at all. Someone flipped a flag, pointed an agent at a repo, and the tool did what tools do: it read everything it was allowed to read and sent a summary of it somewhere.

So here's the invariant I now refuse to run a trial without: no byte from a file whose data class exceeds the mode's ceiling may enter a prompt. Not "sensitive files should be avoided." Not "the model is probably fine." An explicit ceiling per mode, a per-file class, and a gate that fails closed on the unknown.

This post builds that gate with two small scripts, four fixtures, and an exit-code contract you can put in CI today.

Two modes, two boundaries (and one you have to verify yourself)

A hosted free model tier and a self-hosted free server are not the same trust boundary, and conflating them is how teams talk themselves into a bad first week.

In the hosted case, the prompt bytes cross out of your machine. Whether they are retained, logged, or used for anything else is a question for the provider's terms, not for your threat model. You can only control what you send.

In the free-server case, the model weights and the prompt both live on a host you were given. That removes the network hop. It does not remove a tenant boundary, a snapshot, a support login, or a billing integration. Before you raise your data ceiling for that mode, answer one question in writing: who else can read that host's disk? If you can't answer it, keep the ceiling where the hosted mode is.

This is where the free tier is genuinely useful rather than merely cheap. MonkeyCode's current signup advertises a free token allowance and a free server option β€” as of writing, 10 million tokens and a free server, operator-supplied terms you should re-check on the live page before planning around them. What matters for this article is that both boundaries are cheap enough to test instead of argue about. (Disclosure: This article was prepared as part of MonkeyCode's product outreach.)

Step 1 β€” Write the ceiling down as a file, not a vibe

Policy that lives in a wiki gets skipped at 11 p.m. Policy that lives in a dict gets imported. Start with something you can diff in review:

# policy.py β€” your decision, not the vendor's
POLICY = {
    # ceiling = highest class allowed into a prompt at all
    "hosted-free-tier":       {"ceiling": "internal",      "hard": ["secret"]},
    "free-server":            {"ceiling": "pii",           "hard": ["secret"]},
    "air-gapped-self-hosted": {"ceiling": "customer-data", "hard": ["secret"]},
}
RANK = {"public": 0, "internal": 1, "pii": 2, "customer-data": 3, "secret": 4, "unknown": 9}

Two design choices are load-bearing. First, unknown outranks everything, because an unclassified binary is not a safe binary. Second, secret is a hard block in every mode β€” a leaked credential is not a data-classification problem you can solve by picking a friendlier host.

Also note what is not in this file: token counts, model names, or anything you read off a promo page. Promotions change. Your ceiling shouldn't change because a banner did.

Step 2 β€” Classify the tree, and record a digest per file

The classifier below is deliberately dumb. Regex and path heuristics, no ML, no network calls. You want the gate to be explainable in a review meeting, and you want the failure mode to be a false deny, not a false allow.

#!/usr/bin/env python3
# classify.py β€” TEMPLATE. Not run against your repo. Pin your own rules.
import argparse, hashlib, json, re, sys
from pathlib import Path

SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv", "__pycache__"}
MAX_BYTES = 512 * 1024
RANK = {"public": 0, "internal": 1, "pii": 2, "customer-data": 3, "secret": 4, "unknown": 9}

PATH_RULES = [
    ("secret",        re.compile(r"(^|/)(\.env(\..*)?|id_(rsa|ed25519)|\.npmrc|\.pypirc|credentials(\.json)?)$")),
    ("customer-data", re.compile(r"(^|/)(dumps?|exports?|snapshots?|prod[-_]?data)/")),
    ("pii",           re.compile(r"(^|/)(fixtures?|seeds?|samples?)/.*\.(csv|json|sql|ndjson)$")),
]

CONTENT_RULES = [
    ("secret", re.compile(r"(?i)\b(api[_-]?key|secret|password|passwd|token)\b\s*[:=]\s*['\"]?[A-Za-z0-9/_\-+]{16,}")),
    ("secret", re.compile(r"(?i)\b(postgres|mysql|mongodb|redis)://[^\s:@]+:[^\s:@]+@")),
    ("pii",    re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
    # ("pii", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b")),  # keep off until calibrated
]

def digest(p):
    h = hashlib.sha256()
    with p.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

def classify(p, root):
    rel = "/" + p.relative_to(root).as_posix()
    hits = [(cls, "path") for cls, rx in PATH_RULES if rx.search(rel)]
    try:
        if p.stat().st_size > MAX_BYTES:
            return hits, "skipped-too-large"
        text = p.read_text(errors="ignore")
    except OSError:
        return hits, "unknown"
    hits += [(cls, "content") for cls, rx in CONTENT_RULES if rx.search(text)]
    return hits, "text"

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", required=True)
    ap.add_argument("--manifest", required=True)
    a = ap.parse_args()
    root = Path(a.root).resolve()
    files, worst, unknown = [], "public", 0
    for p in sorted(root.rglob("*")):
        if not p.is_file() or any(part in SKIP_DIRS for part in p.parts):
            continue
        hits, kind = classify(p, root)
        cls = "unknown" if kind == "unknown" else max(
            (h[0] for h in hits), key=RANK.get, default="public")
        unknown += kind == "unknown"
        worst = cls if RANK[cls] > RANK[worst] else worst
        files.append({"path": p.relative_to(root).as_posix(), "class": cls,
                      "kind": kind, "sha256": digest(p)})
    json.dump({"worst": worst, "unknown": unknown, "files": files},
              open(a.manifest, "w"), indent=2)
    print(f"worst={worst} files={len(files)} unknown={unknown}")
    sys.exit(4 if worst == "secret" else 3 if unknown else 0)

if __name__ == "__main__":
    main()

The digest is the part people skip and later regret. A manifest without hashes tells you a filename was fine yesterday. A manifest with hashes lets you prove that the file the agent actually read is the file you classified.

Step 3 β€” Gate the manifest per mode

The gate doesn't read your repo. It reads the manifest and answers one question per mode: does this tree fit under this ceiling?

#!/usr/bin/env python3
# gate.py β€” TEMPLATE. Verdict is per mode; there is no global "safe".
import json, sys
from policy import POLICY, RANK

def decide(manifest, mode):
    pol = POLICY[mode]
    bad = [f for f in manifest["files"]
           if f["class"] in pol["hard"] or RANK[f["class"]] > RANK[pol["ceiling"]]]
    return {"mode": mode, "verdict": "deny" if bad else "allow",
            "blocked": len(bad), "sample": [f["path"] for f in bad[:3]]}

if __name__ == "__main__":
    man = json.load(open(sys.argv[1]))
    for mode in POLICY:
        r = decide(man, mode)
        print(f'{mode:24} {r["verdict"]:5} blocked={r["blocked"]:3} {r["sample"]}')

That's it. Two files, no daemon. The interesting output is not allow β€” it's the case where hosted says deny and free-server says allow, because that difference is exactly the argument your team needs to have before the session starts.

Step 4 β€” Build fixtures that must fail

A gate you've only seen pass is a gate you haven't tested. Four fixtures, kept in the repo, deliberately synthetic:

fixtures/deny-secret/app/config.py      # fake DATABASE_URL with inline password
fixtures/deny-pii/fixtures/users.csv    # SSN-shaped rows, no real people
fixtures/allow/app/parser.py
fixtures/allow/README.md

Run the pair and treat the output as an assertion, not as evidence from my machine:

python3 classify.py --root fixtures/deny-secret --manifest /tmp/m.json; echo "exit=$?"
# expect: worst=secret files=2 unknown=0   /   exit=4

python3 classify.py --root fixtures/allow --manifest /tmp/m.json; echo "exit=$?"
# expect: worst=public files=2 unknown=0   /   exit=0

python3 gate.py /tmp/m.json
# expect hosted-free-tier DENY blocked>=1; air-gapped also deny on secret

The exit=4 is the artifact. Wire it into pre-commit and CI, and a credential in a working tree fails the build instead of becoming a training sample.

Step 5 β€” Decide what actually crosses the line

Prompts are the obvious channel. They are not the only one, and usually not the biggest.

Channel Hosted free tier Free server (verify tenancy first)
Prompt text Crosses out; ceiling applies Stays on host; tenancy still applies
Tool file reads Agent may read beyond your prompt Same, plus host disk access
Tool stdout / stack traces Frequently contains secrets Frequently contains secrets
Index or embedding cache Often a second copy, often forgotten Same, on someone's disk
Error reports, telemetry Check the client's defaults Check the server's defaults

If that table has a row you can't fill in, you're not ready to raise a ceiling.

Step 6 β€” Close the loop: deny, detect, recover

Layer Control Owning team
Prevent classify.py in pre-commit, exit 4 on secret Repo owners
Prevent gate.py at session start, fail closed on unknown Platform / harness
Detect Manifest hash compared to the files the agent read Platform
Detect Canary string in fixtures/; alert if it appears in egress logs Security
Recover Rotate, then re-run the fixture suite before re-enabling the mode Security + repo owner

Where this breaks (and who shouldn't use it)

Regex classifiers are dumb, and you should say so out loud. Your first run on a real monorepo will flag hundreds of files, unknown will dominate because of binaries and large generated files, and someone will ask to lower the ceiling to make CI green. That request is the actual test of your process.

It also only covers the working tree. git log -p can resurrect a rotated secret that no current file contains, so run a history scan separately. Submodules are skipped by the walker above unless you recurse deliberately. And nothing here inspects what the agent writes back β€” a patch or a generated script is outbound code that needs its own review gate.

Don't use this as a compliance control, and don't use it to justify putting regulated data on a free tier of any kind, hosted or self-hosted, without a contract and a named data owner. If you can't enumerate your repositories, fix that before you write the policy β€” a gate over an unknown tree is theatre.

If you want to run the same fixture set against both modes before buying anything, the free tier is a reasonable place to do it: run deny-secret against hosted and free-server, and confirm both deny. That's one soft pitch and it's also just the cheapest correct next step.

The boundary question I keep coming back to: classification belongs in CI, on every pull request. The ceiling decision belongs in the harness, at session start, owned by whoever is accountable for the data. Do you need both, or can one layer be trusted to enforce the other's invariant β€” and which one fails quietly if you get it wrong?

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.