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
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
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 == {}
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)}
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
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)