DEV Community

jaryn
jaryn

Posted on

Default-Deny Egress Before Your Agent Meets a Free Model Endpoint

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.

  1. Asset: the plaintext your agent can read — source, .env files, logs, ticket bodies, fixture data with real customer rows.
  2. 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."
  3. Path: prompt assembly → HTTP client → TLS → someone else's inference host.
  4. Invariant: no secret-shaped string and no denied data class ever leaves the host, even when the agent is wrong.
  5. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Load it and confirm it took effect:

sudo nft -f /etc/nftables.d/agent-egress.nft
sudo nft list table inet agent_egress
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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?"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. DNS is a second door. Pin the resolver, or an attacker swaps the name behind an allowed IP.
  3. DoH/DoT bypass. A dependency that ships its own resolver walks around your port-53 rule.
  4. Package installs break. Default-deny means your mirror must be on the allowlist, or builds fail at 2 a.m.
  5. 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)

Collapse
 
sikamikanikobg profile image
Arsen Apostolov •

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.