DEV Community

Emery Li
Emery Li

Posted on

The Redaction Gate: What Must Never Leave the Host When an Agent Needs a Cloud Model

A team ships a local-first coding agent that is allowed to call a hosted model only for "hard" tasks. One support ticket arrives with a pasted .env excerpt, and the agent forwards the whole file to the cloud leg to "understand context better". Nobody notices for three days, because the run succeeded, the tests passed, and the trace looked clean.

That scenario is illustrative rather than a report from a specific incident, but the failure mode is common enough to plan for. Most local-first discussions frame the decision as latency versus cost, while the expensive mistake is usually about what data crossed a boundary. This article builds a small, testable redaction gate, then places it next to a measurement of two cold-start floors so the placement choice becomes evidence-driven instead of habitual.

Why the data boundary deserves its own component

A placement decision that lives only in configuration will eventually drift, because configs are edited by people under deadline pressure. A redaction gate that runs as code before every cloud call turns the boundary into an assertion the test suite can fail on. The gate also gives the local leg a useful role even when it never generates a token, since classification and masking are cheap local operations.

Recent community discussion has been dominated by broad arguments about AI replacing engineers, which produces heat but few artifacts a team can adopt. The narrower engineering question is more tractable: which fields may be sent, which must be masked, and which must stay on the host no matter how good the hosted model is. An agent that cannot answer that question in code is not ready for regulated or customer-facing work.

Step 1: Classify context into three egress classes

Start with three classes and resist adding a fourth until the first three are enforced in CI. local_only content must never appear in a request body, redact_then_cloud content may travel after masking, and cloud_ok content is ordinary task material such as a stack trace with no identifiers. The classification below is intentionally regex-based, because a deterministic gate is easier to audit than a model that guesses.

# egress_gate.py
from __future__ import annotations

import re
from dataclasses import dataclass
from enum import Enum


class Egress(str, Enum):
    LOCAL_ONLY = "local_only"
    REDACT_THEN_CLOUD = "redact_then_cloud"
    CLOUD_OK = "cloud_ok"


@dataclass(frozen=True)
class Rule:
    name: str
    pattern: re.Pattern[str]
    verdict: Egress


RULES: tuple[Rule, ...] = (
    Rule("db_url", re.compile(r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?)://\S+"), Egress.LOCAL_ONLY),
    Rule("api_key", re.compile(r"\b(?:sk|ghp|xox[baprs])[-_][A-Za-z0-9]{16,}\b"), Egress.LOCAL_ONLY),
    Rule("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), Egress.REDACT_THEN_CLOUD),
    Rule("private_ip", re.compile(r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))(?:\.\d{1,3}){3}\b"), Egress.REDACT_THEN_CLOUD),
    Rule("card_like", re.compile(r"\b(?:\d[ -]?){13,19}\b"), Egress.REDACT_THEN_CLOUD),
)


def classify(text: str) -> list[tuple[str, Egress]]:
    return [(r.name, r.verdict) for r in RULES if r.pattern.search(text)]


def decide(text: str) -> Egress:
    hits = classify(text)
    if any(v is Egress.LOCAL_ONLY for _, v in hits):
        return Egress.LOCAL_ONLY
    if any(v is Egress.REDACT_THEN_CLOUD for _, v in hits):
        return Egress.REDACT_THEN_CLOUD
    return Egress.CLOUD_OK
Enter fullscreen mode Exit fullscreen mode

The ordering in decide matters, and it should be spelled out in a comment so a future contributor does not "simplify" it. A payload that contains both a database URL and an email address is local_only, because the most restrictive verdict wins. That single rule prevents the most common silent leak, where masking succeeds and the unmasked field rides along unnoticed.

Step 2: Mask with stable placeholders and keep the map on the host

Masking needs to be reversible locally and irreversible remotely. Replace each match with a typed placeholder such as <REDACTED:email:1>, and keep the placeholder-to-value mapping in memory for the duration of the run only. Writing that map to disk turns the gate into a new secret store, which defeats the purpose.

PLACEHOLDER = "<REDACTED:{name}:{n}>"


def redact(text: str) -> tuple[str, dict[str, str]]:
    mapping: dict[str, str] = {}
    out = text
    counters: dict[str, int] = {}
    for rule in RULES:
        if rule.verdict is Egress.CLOUD_OK:
            continue

        def _sub(match: re.Match[str], rule: Rule = rule) -> str:
            counters[rule.name] = counters.get(rule.name, 0) + 1
            token = PLACEHOLDER.format(name=rule.name, n=counters[rule.name])
            mapping[token] = match.group(0)
            return token

        out = rule.pattern.sub(_sub, out)
    return out, mapping
Enter fullscreen mode Exit fullscreen mode

Because placeholders are typed and numbered, a hosted model can still reason about structure without seeing values. A troubleshooting prompt that says "the connection string in <REDACTED:db_url:1> fails on TLS" remains useful, and the operator can rehydrate the answer locally. Numbered placeholders also make logs easier to compare across retries, since the same field keeps the same token within one run.

Step 3: Prove the gate with a canary corpus and a fixed-point test

The important property is not that masking runs, but that the masked output cannot be re-flagged by the same rules. That fixed-point check catches partial masks, overlapping patterns, and placeholder formats that accidentally resemble their own targets. Pair it with a small synthetic corpus whose values are fabricated, never copied from real configuration.

# tests/test_egress_gate.py
import pytest

from egress_gate import Egress, classify, decide, redact

CANARIES = {
    "db_url": "postgres://svc:hunter2@db.internal:5432/app",
    "api_key": "sk-canary0000000000000000",
    "email": "canary.person@example.com",
    "private_ip": "10.4.19.7",
    "card_like": "4111 1111 1111 1111",
}


@pytest.mark.parametrize("name,value", CANARIES.items())
def test_no_canary_survives_redaction(name, value):
    masked, _ = redact(f"context: {value} end")
    assert value not in masked
    assert all(hit_name != name for hit_name, _ in classify(masked))


def test_most_restrictive_verdict_wins():
    blob = f"{CANARIES['db_url']} and {CANARIES['email']}"
    assert decide(blob) is Egress.LOCAL_ONLY


def test_mapping_is_not_needed_for_cloud_ok():
    masked, mapping = redact("plain stack trace without identifiers")
    assert masked == "plain stack trace without identifiers"
    assert mapping == {}
Enter fullscreen mode Exit fullscreen mode

Run the suite with python -m pytest tests/test_egress_gate.py -q and treat any failure as a release blocker for the cloud leg. The corpus grows every time someone adds a rule, which keeps the gate honest as the product surface changes. A rule without a canary is a comment, not a control.

Step 4: Measure both floors on the same host and the same hour

Latency claims age badly, so the method matters more than any number quoted in a post. Probe the local warm-up path and the network path with identical instrumentation, and record the host state alongside the samples. The snippet below measures a command's wall time and reports a p95 index, which is deliberately simple to keep the probe auditable.

# floor_probe.py
import statistics
import subprocess
import time


def probe(cmd: list[str], runs: int = 7) -> dict[str, float]:
    samples: list[float] = []
    for _ in range(runs):
        started = time.perf_counter()
        subprocess.run(cmd, check=True, capture_output=True)
        samples.append(time.perf_counter() - started)
    samples.sort()
    idx = max(0, min(len(samples) - 1, int(round(0.95 * (len(samples) - 1)))))
    return {"p50": statistics.median(samples), "p95": samples[idx], "runs": float(runs)}
Enter fullscreen mode Exit fullscreen mode

Re-run the probe after any host sleep, because a suspended machine silently invalidates earlier samples and makes a warm local path look slow. Two runs eighteen hours apart are not comparable if the second one started from a cold cache. Record the timestamp, the host state, and the command in one line of structured log so the comparison stays defensible.

Step 5: Turn the measurements into a placement table

The gate and the probe meet in a decision table that anyone on the team can apply without re-reading the architecture doc. The table below encodes policy, not benchmark results, so no measured value has to be invented to use it.

Secret class in context Offline required Local floor acceptable Placement
local_only present and unresolvable any any local only, fail closed
redact_then_cloud only no yes local first, escalate if task fails
redact_then_cloud only no no redact, then cloud
redact_then_cloud only yes any local only, queue for later review
cloud_ok only no irrelevant cloud leg directly

"Local floor acceptable" means the measured warm-path p95 fits inside the task's interaction budget on the actual host. If the local floor exceeds that budget, the redacted cloud path is the correct choice rather than a compromise. If the context contains an unresolvable local_only field, the run stops, and a human decides whether to rewrite the prompt.

Step 6: Wire the gate into CI and the run path

Two integration points are enough for most teams, and both are cheap to add. In CI, run the canary suite on every pull request, and add a job that fails if egress_gate.py imports network libraries. In the run path, call decide before constructing any hosted request body and log the verdict with the run identifier.

python -m pytest tests/test_egress_gate.py -q
python -m floor_probe --cmd "python -m local_model.warmup" --runs 7 || true
git grep -nE "^(import|from) (httpx|requests|openai)" -- egress_gate.py && exit 1 || true
Enter fullscreen mode Exit fullscreen mode

The import check is crude and occasionally annoying, which is exactly why it works. It makes the boundary violation visible in review instead of discoverable after a support ticket. Teams that prefer a linter rule can replace it, as long as the enforcement stays automatic.

Where a free server leg fits, and where it does not

The cloud column of that table needs a real endpoint to be useful, and this is where MonkeyCode is relevant: the project offers free model access and a free server option, so the redacted cloud path can be exercised without a procurement cycle. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free access is operator-supplied information, so verify the current quota, model list, and server terms on the project's own documentation before you depend on them.

Two limitations deserve equal billing. First, a free server leg is still a third party for data-handling purposes, so the redaction gate must run before the request leaves the host, not inside the hosted environment. Second, free tiers change, which means the placement table should degrade gracefully to the local path rather than assume the cloud leg is always available; the project is described as open source, so check the repository's license and deployment notes if continuity matters.

Who should not use this approach

Teams that never send context to a hosted model gain nothing from a redaction gate, and should skip it entirely. Workloads dominated by large binary artifacts need a content-addressed policy rather than regex masking, because pattern matching on binaries produces noise. Organisations with a formal data-classification schema should map these three classes onto their existing labels instead of inventing a parallel vocabulary.

Use a hosted classifier or a small local model for the classification step if your data has identifiers that regexes cannot describe, such as free-text medical notes. In that case, keep the deterministic rules as a second pass and treat the model output as advisory. The gate should fail closed whenever the two disagree, since a false positive costs a slower run while a false negative costs an incident.

A short closing note

The reusable takeaway is a single sentence a reviewer can check: no request body leaves the host until a test proves that the forbidden fields were removed. Everything else, from the placeholder format to the placement thresholds, follows from that assertion. If you maintain such a gate, run it once against the redacted cloud path end to end, and confirm the masked payload still completes a real task before you trust the table.

Top comments (0)