Here is a script I keep watching play out. A team reads that a free tier exists, spins up an agent on someone else's host, points it at a hosted model, and ships a demo by Friday. Nobody asks where the data went until an auditor does.
So let me ask the uncomfortable version early: if your agent runs on a host you don't fully control, what exactly is stopping it from sending your repository somewhere you never approved?
I'm not going to re-report benchmark news I haven't verified myself. But the trend signal is easy to read. DEV's front page this week is full of AI fatigue, "AI got better while software didn't," and posts about thousands of open-source AI projects landing in a single weekend. Teams respond by trying free tiers. And a free tier is exactly when the trust-boundary question gets real, because you're adopting two new boundaries at once.
This post is a security walkthrough. Threat model first, then two enforcement layers you can actually test, then a decision table for what may cross which boundary. Everything below is runnable fixture code, not a policy document.
The threat model, in five lines
Write this down before you write any agent code. If you can't fill all five lines, you don't have a threat model, you have a vibe.
-
Asset: the plaintext your agent can read — source,
.envfiles, logs, ticket bodies, fixture data with real customer rows. - Actor: anything that can influence the payload. That includes prompt injection from a fetched file, a compromised dependency, and a helpful teammate who pastes a token "just for testing."
- Path: prompt assembly → HTTP client → TLS → someone else's inference host.
- Invariant: no secret-shaped string and no denied data class ever leaves the host, even when the agent is wrong.
- Evidence: a failing canary connection and a failing unit test, both executed in CI.
Note line four. The invariant has to survive an agent that is confidently incorrect, because that is the normal state of agents.
Two boundaries, and only one can see plaintext
This is the part people get wrong, and it costs them a false sense of safety.
| Layer | Where it lives | What it can see | What it can enforce |
|---|---|---|---|
| Content gate | Your process, before encryption | Full plaintext payload | Regex/classifier rules on strings |
| Destination gate | The kernel, on the host | IP, port, protocol, volume | Allowlist, default deny, counters |
The content gate can read everything and can be bypassed by any code path that skips it. The destination gate cannot read anything, and cannot be bypassed by application code — but it also can't tell a secret from a shopping list. You need both, and you need to know which one is load-bearing when they disagree.
Step 1 — Write the egress contract before you write the agent
The contract is a small file that both layers read. One source of truth, two enforcement points.
{
"version": 1,
"reviewed_at": "2026-10-16",
"allowed_destinations": ["203.0.113.10:443"],
"resolver": "203.0.113.53:53",
"data_classes": {
"public_docs": "send",
"internal_source": "send_after_review",
"customer_pii": "never",
"credentials": "never",
"logs_with_tokens": "never"
}
}
Two rules for this file. Pin the destination by IP and port, not by hostname — DNS is a second trust boundary and I'd rather not argue about it in a review meeting. Second, date it. An undated contract is a rumor.
Step 2 — Enforce destinations in the kernel
On a host where you control the kernel, this is the whole game. Default deny, allow the one endpoint, count and log the rest.
#!/usr/sbin/nft -f
# /etc/nftables.d/agent-egress.nft
table inet agent_egress {
set allowed_dst {
type ipv4_addr
flags interval
elements = { 203.0.113.10 }
}
chain output {
type filter hook output priority 0; policy drop;
oifname "lo" accept
ct state established,related accept
ip daddr @allowed_dst tcp dport 443 accept
ip daddr 203.0.113.53 udp dport 53 accept
meta nfproto ipv6 drop
counter log prefix "agent-egress-drop " level warn
}
}
Load it and confirm it took effect:
sudo nft -f /etc/nftables.d/agent-egress.nft
sudo nft list table inet agent_egress
Now prove it with a canary. A rule you haven't tested is a comment with extra steps.
CANARY=203.0.113.99
if timeout 5 bash -c "exec 3<>/dev/tcp/${CANARY}/443" 2>/dev/null; then
echo "FAIL: canary destination reachable"; exit 1
else
echo "PASS: egress blocked"
fi
sudo nft list table inet agent_egress | grep -c "agent-egress-drop"
If the canary connects, stop. Fix the policy before you debug the agent.
Step 3 — Enforce content where plaintext exists
Kernel rules can't read a payload, so the string policy has to live in the process that builds the request. Keep it dumb and fast.
# gate/preflight.py
import json, re
SECRET_PATTERNS = {
"aws_access_key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
"github_pat": re.compile(r"\bghp_[A-Za-z0-9]{36}\b"),
"private_key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
"bearer": re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{20,}"),
}
DENY_CLASSES = {"customer_pii", "credentials", "logs_with_tokens"}
class EgressViolation(Exception):
"""Raised before bytes leave the process."""
def assert_payload_allowed(payload: str, contract: dict, declared: str) -> str:
if contract["data_classes"].get(declared) == "never" or declared in DENY_CLASSES:
raise EgressViolation(f"data class '{declared}' is not sendable")
hits = sorted(n for n, rx in SECRET_PATTERNS.items() if rx.search(payload))
if hits:
raise EgressViolation(f"payload matched {hits}")
return payload
if __name__ == "__main__":
contract = json.load(open("egress.contract.json"))
print(assert_payload_allowed("how do I pin nftables?", contract, "public_docs"))
And the fixture pair, because one-sided tests are theatre:
# tests/test_preflight.py
import pytest
from gate.preflight import EgressViolation, assert_payload_allowed
CONTRACT = {"data_classes": {"public_docs": "send", "customer_pii": "never"}}
def test_negative_secret_shaped_string_is_blocked():
with pytest.raises(EgressViolation):
assert_payload_allowed("token: ghp_" + "a" * 36, CONTRACT, "public_docs")
def test_negative_denied_class_is_blocked():
with pytest.raises(EgressViolation):
assert_payload_allowed("email a@b.example", CONTRACT, "customer_pii")
def test_positive_public_question_passes():
assert assert_payload_allowed("regex help?", CONTRACT, "public_docs") == "regex help?"
Step 4 — Wire both layers into one gate
Enforcement that isn't in CI is a suggestion.
# .github/workflows/egress.yml
name: egress-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pytest
- run: pytest -q tests/test_preflight.py
- run: bash scripts/canary.sh
The unit tests catch content regressions in seconds. The canary catches the day someone relaxes the firewall "temporarily." Different failure modes, different layers, same gate.
Decision table: what may cross which boundary
| Data class | Send to hosted model? | Store on the host? | Enforced by |
|---|---|---|---|
| Public docs, OSS source | Yes | Yes | Nothing — it's already public |
| Internal source | Only after review, pinned revision | Yes | Content gate + review |
| Customer PII | No | Only if already required by policy | Content gate, deny class |
| Credentials, tokens | No | Canary/short-lived only | Both layers, deny class |
| Logs with tokens | No | Redact at write time | Content gate, plus log sink policy |
Where a free tier genuinely helps, and where it doesn't
Here's the honest version of the pitch. Two things matter for this workflow.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
First, the free model access matters because this whole approach depends on repeatedly running negative fixtures — canary probes, secret-shaped payloads, denied data classes. That is cheap, boring traffic, and paying per token to run it is silly. Second, the free server option matters because Layer 2 only exists if you control the kernel. On a pure client-side SaaS you can't write an output chain; you can only hope the application calls your gate.
If the host you get is a locked-down container without NET_ADMIN, you cannot load that nftables table — plan for substituting a forced proxy plus a resolver you control, or accept that you only have Layer 1. Check before you design around it.
The project advertises a free tier that at the time of writing includes 10M tokens and a free server. Treat that as operator-supplied and verify the current limits yourself on the project's own page before you build a budget on it; quotas move, and dated assumptions rot fast.
If you want to try the negative-fixture suite against an endpoint you don't own, a free tier is a reasonable place to start — just bring your own contract.
Limitations and failure modes
Be clear-eyed about what this does not do.
- TLS is opaque to you. The destination gate sees IPs and ports. You cannot content-inspect the inference request without terminating TLS with a CA you control, which means running a broker you now have to secure too.
- DNS is a second door. Pin the resolver, or an attacker swaps the name behind an allowed IP.
- DoH/DoT bypass. A dependency that ships its own resolver walks around your port-53 rule.
- Package installs break. Default-deny means your mirror must be on the allowlist, or builds fail at 2 a.m.
- Counters lie by omission. If the log rule is the only evidence and nobody reads it, you have detection without detection.
Who should not use this
Don't build this for a throwaway weekend experiment on public sample data — the overhead isn't worth it, and default-deny rules will just slow you down. Don't use it if you need full payload inspection of encrypted traffic and you're not prepared to run a TLS-terminating broker with its own threat model. And don't adopt it as a substitute for data classification: the gate enforces what you declared. If your team can't say which column is PII, no firewall will guess for you.
The boundary question
The gate works when both layers are boring and both are tested. So the question I'd leave you with isn't whether to allowlist — it's where the decision belongs. Should the invariant "this data class never leaves" live in CI as a unit test, or in the kernel as a rule that ignores your application entirely?
I have an opinion, but it depends on your blast radius. Which layer would you trust when the agent is confidently wrong?
Top comments (1)
the two-boundary split is the part i keep relearning. the kernel layer can't read the payload, and the content layer can be skipped by any code path, so the only way to know which one is load-bearing is to make them disagree on purpose — which is exactly what the canary does, and it is the step everyone skips.
on my own box i run a self-hosted agent that talks to a local model, and the thing i add to this setup is the view of the host itself. i keep a little monitor that reads nvidia-smi per process — the vram and the watts on the model server, and the containers around it (github.com/SikamikanikoBG/homelab-monitor) — because the moment the agent starts fetching, the gpu and the network both move, and i want to see that movement before the log counter does. a canary that fails in ci and a process that quietly opens a second connection are the same failure, just at different times.