DEV Community

jaryn
jaryn

Posted on

Which Files May Enter the Model Window? Build a Context Admission Gate

Which Files May Enter the Model Window? Build a Context Admission Gate

Synthetic payload. Invented values. The shape is the problem.

{
  "endpoint": "<your model gateway>",
  "files": [".env", "src/app.py", "infra/terraform.tfstate"],
  "prompt": "why does staging fail to boot?"
}
Enter fullscreen mode Exit fullscreen mode

Three files, one question, 41 KB on the wire. Nothing here is broken at the transport layer. TLS is fine, the token is scoped, the answer comes back in under a second. And yet the context assembler just handed a database password and an entire Terraform state file to an endpoint whose retention policy nobody on the team can name out loud.

How did it decide those files were in scope? It didn't. It globbed **/*, sorted by mtime, and stopped when the token budget ran out. That's not malicious. It's just a six-line function nobody reviewed, sitting on the most sensitive boundary in your stack.

So let's close that gap. Not "is the model trustworthy" — that's a different essay. The question here is narrower and answerable: what is allowed to reach the endpoint at all, and how do you prove it stays that way next Tuesday.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source, self-hosted AI development platform. The operator states that it currently offers free model access alongside a free server option. Treat any quota number you see quoted — including the 10M-token free tier — as a claim to re-check on the project page the day you sign up, not as a capacity plan.


1. Name the boundaries before you write a single regex

Every rule below exists because of one row in this table. If a row isn't real for you, delete the rule — an unreviewable gate is worse than no gate, because it teaches people to bypass it.

Boundary Asset at risk Who can read it after crossing Typical failure
Working tree → assembler .env, keys, state files any process in the sandbox glob miss pulls a dotfile
Assembler → endpoint the whole prompt vendor, transit, request logs over-broad context, long retention
Endpoint → response nothing yet you repo content carries injected instructions
Assembler → local log a copy of the prompt anyone with disk access secrets archived "for debugging"

Row two is the one people underestimate. You cannot un-send a prompt. Rotation is a recovery step, not a prevention step, and it's expensive at 2 a.m.

Here's the thing that changes with a free endpoint: the boundary doesn't move, but the stakes per mistake go up, because a free tier is where you experiment fastest and review least. Same rule, more exposure. Which is why the gate belongs in the assembler — client side — and not in a promise about the other end.


2. Classify files into four buckets, not two

Binary thinking ("secret" vs "fine") collapses immediately on real repos. Use four buckets instead:

  1. Deny — credential-shaped paths and files. Never admitted, regardless of size or prompt.
  2. Quarantine — content that might hold a high-entropy token. A human decides.
  3. Admit — ordinary source, docs, tests, config without values.
  4. Never read — binaries, lockfile blobs, vendored trees. Not a security call, a signal-to-noise call.

A file's path is a cheap, high-precision signal. A file's content is an expensive, low-precision one. Apply the path check first and you eliminate most of the risk before you've burned a millisecond on entropy math.


3. The gate: forty lines you can actually review

Below is a template, not an audited product. I have not run it against your repo, and it should be pinned and reviewed like any other code on a trust boundary.

# context_gate.py — TEMPLATE. Pin it, review it, test it.
from __future__ import annotations
import math, re, sys, json
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path

@dataclass(frozen=True)
class Decision:
    path: str
    verdict: str   # "admit" | "quarantine" | "deny"
    reason: str

DENY_GLOBS = (
    ".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
    "id_rsa*", "id_ed25519*", ".npmrc", ".pypirc", ".netrc",
    "*.tfstate", "*.tfstate.backup", "*kubeconfig*", "*.jks",
)
DENY_DIRS = {".git", "node_modules", ".venv", "secrets", "private"}

# Deliberately boring, high-precision patterns. No clever ones.
PATTERNS = {
    "cloud_key":    re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
    "private_key":  re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----"),
    "bearer":       re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{20,}"),
    "jwt":          re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
    "email":        re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b"),
    "internal_host": re.compile(r"\b[a-z0-9-]+\.(?:internal|corp|local)\b"),
}

def shannon(s: str) -> float:
    if not s:
        return 0.0
    counts: dict[str, int] = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    n = len(s)
    return -sum((c / n) * math.log2(c / n) for c in counts.values())

def entropy_hits(text: str, threshold: float = 4.2, min_len: int = 24, cap: int = 20) -> list[str]:
    hits: list[str] = []
    for tok in re.findall(r"[A-Za-z0-9+/=_-]{%d,}" % min_len, text):
        if shannon(tok) >= threshold:
            hits.append(tok)
            if len(hits) >= cap:
                break
    return hits

def classify(root: Path, rel: str, max_bytes: int = 256_000) -> Decision:
    p = root / rel
    parts = set(Path(rel).parts)
    if parts & DENY_DIRS:
        return Decision(rel, "deny", f"denied dir: {sorted(parts & DENY_DIRS)}")
    for g in DENY_GLOBS:
        if fnmatch(p.name, g) or fnmatch(rel, g):
            return Decision(rel, "deny", f"deny glob: {g}")
    try:
        if p.stat().st_size > max_bytes:
            return Decision(rel, "deny", "oversize for context budget")
        text = p.read_text(encoding="utf-8", errors="strict")
    except (OSError, UnicodeDecodeError):
        return Decision(rel, "deny", "unreadable or binary")
    for name, rx in PATTERNS.items():
        if rx.search(text):
            return Decision(rel, "deny", f"pattern: {name}")
    if entropy_hits(text):
        return Decision(rel, "quarantine", "high-entropy token candidates")
    return Decision(rel, "admit", "no rule matched")

if __name__ == "__main__":
    root = Path(sys.argv[1]).resolve()
    decisions = [
        classify(root, str(f.relative_to(root)))
        for f in sorted(root.rglob("*")) if f.is_file()
    ]
    print(json.dumps([d.__dict__ for d in decisions], indent=2))
    sys.exit(0 if all(d.verdict == "admit" for d in decisions) else 2)
Enter fullscreen mode Exit fullscreen mode

Run it in report mode first:

python context_gate.py . | jq -r '.[] | [.verdict, .path, .reason] | @tsv' | sort | head -50
Enter fullscreen mode Exit fullscreen mode

Exit code 2 means "something needs a human", which is what you want a CI job to see. Exit code 0 means the tree is admissible as-is.


4. Fixtures: positive and negative, asserting on the reason

A test that only checks verdict == "deny" will happily pass when your path join is broken. Assert the reason — that's the failure evidence.

# test_context_gate.py — synthetic values only
from pathlib import Path
from context_gate import classify

def write(root: Path, name: str, body: str) -> None:
    p = root / name
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(body, encoding="utf-8")

def test_env_is_denied_by_path(tmp_path: Path) -> None:
    write(tmp_path, ".env", "DB_PASSWORD=synthetic-not-real\n")
    d = classify(tmp_path, ".env")
    assert d.verdict == "deny"
    assert d.reason.startswith("deny glob"), d.reason

def test_terraform_state_is_denied_by_path(tmp_path: Path) -> None:
    write(tmp_path, "infra/terraform.tfstate", '{"resources": []}')
    d = classify(tmp_path, "infra/terraform.tfstate")
    assert d.verdict == "deny"
    assert "tfstate" in d.reason

def test_key_block_is_denied_by_content(tmp_path: Path) -> None:
    write(tmp_path, "src/notes.txt", "-----BEGIN PRIVATE KEY-----\nsynthetic\n-----END PRIVATE KEY-----")
    d = classify(tmp_path, "src/notes.txt")
    assert d.verdict == "deny"
    assert d.reason == "pattern: private_key"

def test_entropy_token_is_quarantined(tmp_path: Path) -> None:
    write(tmp_path, "src/cfg.py", 'TOKEN = "Zx9Qm2Lp7Rt4Vb8Nw1Kc6Yh3J"\n')
    d = classify(tmp_path, "src/cfg.py")
    assert d.verdict == "quarantine", d.reason

def test_plain_source_is_admitted(tmp_path: Path) -> None:
    write(tmp_path, "src/app.py", "def boot(port: int) -> None:\n    print(port)\n")
    d = classify(tmp_path, "src/app.py")
    assert d.verdict == "admit", d.reason

def test_binary_is_denied(tmp_path: Path) -> None:
    (tmp_path / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\xff")
    d = classify(tmp_path, "logo.png")
    assert d.verdict == "deny"
    assert d.reason == "unreadable or binary"
Enter fullscreen mode Exit fullscreen mode

Run it: python -m pytest test_context_gate.py -q. Six tests, six boundaries, no network required. That's the whole point — the fixture is the regression test, and it never talks to an endpoint.


5. Baseline before you enforce

Do not flip the gate to blocking on day one. You'll drown in false positives and someone will disable it by Friday. Instead:

  1. Run report mode across the repo, twice: once on main, once on your messiest feature branch.
  2. Count quarantine hits per 100 files. A large number usually means one generated directory you forgot to exclude.
  3. Add path-level excludes for generated trees until the count is dominated by genuine review items.
  4. Only then make exit code 2 block the assembler.

I care much less about the threshold you pick than about the fact that you looked at the list before enforcing it.


6. Prevent, detect, recover — three different jobs

Layer Mechanism What it actually buys you
Prevent path deny globs + content patterns in the assembler the common secrets never leave
Detect quarantine list reviewed before send; CI exit code 2 you learn your repo's real shape
Recover rotate the credential, purge the artifact, fix the glob bounded damage when a rule misses

The gate is a prevent-and-detect tool. It is not a recovery plan, and it will never be one. If a credential crossed the boundary, the only correct next step is rotation — the gate's job is to make that step rarer, not to make it unnecessary.


7. What this does not do, and who shouldn't bother

Let me be blunt about the limits, because a security post without limits is marketing:

  • It doesn't stop prompt injection. A README that says "ignore prior instructions" passes every rule above if it's plain text. This gate governs egress of data, not control of behavior.
  • Entropy scanning has both false positives and false negatives. Minified JS, lockfile hashes, and base64 assets will trip it. A 24-character secret with an English word in it may not.
  • It can't stop inference. If admitted source reveals the shape of your auth check, the model can describe it. Admission is not a NDA.
  • It's client-side. Anyone running a different assembler bypasses it entirely. Pair it with a gateway policy or it's a habit, not a control.

Who should skip this? Solo prototypes with no customer data and no credentials in the tree — the cost of the gate exceeds the risk. Teams that legitimately need to send whole repos for analysis — write the exception list first, or you'll create a permanent bypass. And anyone who won't run the baseline: an unreviewed blocklist is just a future incident with extra steps.


Where this fits in a trial

If you're evaluating a self-hosted platform — MonkeyCode included — the gate is a two-line integration: call classify() in the assembler, refuse on deny, queue on quarantine. It works the same against a free endpoint, a paid one, or a local model, which is exactly the property you want from a boundary rule. Run it in report mode on your first day and read the list before your second.

So: which invariant belongs in the gate, and which one belongs in the repository's own CI? My answer, for what it's worth — path rules in the assembler, content rules in CI, and rotation in neither, because rotation is a human procedure and pretending otherwise is how teams end up with a green checkmark and a leaked key.

Top comments (0)