A solo founder can vibe-code a weekend backend and still keep the bill at zero. The handlers can be messy. The outbound host list cannot. In this lane, engineering is a runtime host permit that fails closed before any paid hostname is resolved.
Generated code is good at looking complete. It is bad at respecting a bootstrapped budget. A draft that compiles is not a draft that stays free. The difference is not taste. It is whether the process is allowed to resolve the public internet.
The leak that import pinning misses
Import scanners catch branded SDKs. They miss a one-line urllib.request.urlopen aimed at a vendor the model remembered from training data. They also miss background “health” pings the agent added while being helpful. Those calls do not need a new dependency. They need a name.
Name resolution is the cost event. After DNS succeeds, the bill is a matter of time. A permit file that wraps getaddrinfo is the cheapest in-process control a solo operator can keep. It is not a service mesh. It is a lock on the laptop.
Vibe-coded features can ship today under that lock. Calling the result a platform is the mistake. The permit is the engineering. The generated route handlers are the draft.
Permit classes for a zero-bill demo
Three classes are enough for a weekend ship. Extra classes become prompt fodder. Keep the table small enough to hash in CI.
| Class | Meaning | Typical members | Default |
|---|---|---|---|
| LOCAL | Loopback only |
localhost, 127.0.0.1, ::1
|
allow |
| FREE_REMOTE | One operator-chosen free host | a single explicit hostname | deny until listed |
| PAID | Everything else | * |
deny |
LOCAL is the demo. FREE_REMOTE is optional. PAID is never an accident. A founder who needs a second remote has left the zero-bill lane and should say so in the permit diff, not in chat.
1. Write a boring permit file
Keep the file next to the app, not in a prompt. Models rewrite prompts. They are slower to rewrite a path that CI hashes. First match wins. The wildcard deny is required, not decorative.
# egress.permit
# format: ALLOW|DENY <host-or-ip>
# comments start with #
ALLOW localhost
ALLOW 127.0.0.1
ALLOW ::1
DENY *
A founder who later opts into a free server adds one ALLOW line for that hostname. Nothing else changes. The wildcard deny stays last. Treat a growing file as an incident, not as product iteration.
Hash it so an agent cannot silently widen the net.
sha256sum egress.permit > egress.permit.sha256
git add egress.permit egress.permit.sha256
2. Install a process-wide resolver guard
The guard below is a compact example, not a security product. It wraps socket.getaddrinfo and refuses unmatched hosts before a TCP handshake. Load it before the ASGI app. A late import is an open window.
# permit_guard.py
from __future__ import annotations
import ipaddress
import socket
from pathlib import Path
PERMIT_PATH = Path(__file__).with_name("egress.permit")
_DENIED = object()
class PermitDenied(RuntimeError):
pass
def _parse(path: Path) -> list[tuple[str, str]]:
rules: list[tuple[str, str]] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
action, _, target = line.partition(" ")
action = action.upper()
target = target.strip().lower()
if action not in {"ALLOW", "DENY"} or not target:
raise ValueError(f"invalid permit line: {raw!r}")
rules.append((action, target))
if not rules or rules[-1] != ("DENY", "*"):
raise ValueError("egress.permit must end with DENY *")
return rules
RULES = _parse(PERMIT_PATH)
def _host_key(host: str) -> str:
host = host.strip().lower().rstrip(".")
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
return host
def decide(host: str) -> str:
key = _host_key(host)
try:
ipaddress.ip_address(key)
identities = {key}
except ValueError:
identities = {key}
for action, target in RULES:
if target == "*" or target in identities:
return action
return "DENY"
_orig_getaddrinfo = socket.getaddrinfo
def _guarded_getaddrinfo(host, port, *args, **kwargs):
if host is None:
return _orig_getaddrinfo(host, port, *args, **kwargs)
if decide(str(host)) != "ALLOW":
raise PermitDenied(f"egress denied for host={host!r} port={port!r}")
return _orig_getaddrinfo(host, port, *args, **kwargs)
socket.getaddrinfo = _guarded_getaddrinfo
The wrapper is English-simple on purpose. It does not log denied hosts to a remote sink. Logging is another egress path. Print locally or fail the test. Do not phone home about a deny.
3. Add a hostile test the agent cannot charm
The suite must call a public hostname the permit does not list. If the suite is green without that failure, the guard is not installed. A loopback-only happy path is a false calm.
# test_egress_permit.py
import permit_guard
import socket
import urllib.error
import urllib.request
import pytest
def test_loopback_still_resolves():
infos = socket.getaddrinfo("127.0.0.1", 9, type=socket.SOCK_STREAM)
assert infos
def test_public_host_is_denied():
with pytest.raises(permit_guard.PermitDenied):
socket.getaddrinfo("example.com", 443, type=socket.SOCK_STREAM)
def test_stdlib_http_cannot_bypass_the_resolver():
with pytest.raises((permit_guard.PermitDenied, urllib.error.URLError)):
urllib.request.urlopen("https://example.com/", timeout=2)
Run it on every agent session, not once per release fantasy.
python -m pytest -q test_egress_permit.py
python -c "import permit_guard, hashlib; print(hashlib.sha256(open('egress.permit','rb').read()).hexdigest())"
Pin the hash check next to pytest. A permit that drifted is a failed build. A model that “simplified” DENY * into a comment is a failed build.
4. Draft with a free model; list a free server only if it is real
A solo operator can draft handlers with a free coding model and still treat that model as untrusted for network policy. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project with free model access and a free server option. Those are availability claims. They are not a default ALLOW rule.
If a free server is used, it belongs on one explicit ALLOW line under the operator’s control. The permit does not infer it from marketing copy. The model does not get a second remote “for retries.” CI should fail when egress.permit grows without a human diff. Indie operators who want that draft lane can try MonkeyCode against this permit workflow. The wildcard deny stays the last line either way.
The rest of the method does not depend on any vendor. Remove the product and the guard still compiles. That is the point of putting policy in a file the process loads first.
5. Ship with the guard in the process, not in the README
README policy is not policy. Export one entry point that imports the guard before the app. A tired 01:00 restart should not discover a second, unguarded script.
# Makefile
.PHONY: demo test freeze
demo:
python -c "import permit_guard, app; app.main()"
test:
python -m pytest -q test_egress_permit.py
freeze:
sha256sum -c egress.permit.sha256
Keep app.py boring. Bind the demo server to loopback. Refuse 0.0.0.0 unless the founder is doing that on purpose and has already left the laptop-only contract.
# app.py — labeled example, not a framework
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = b'{"ok": true, "lane": "local"}\n'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main() -> None:
httpd = HTTPServer(("127.0.0.1", 8080), Handler)
httpd.serve_forever()
Debugging a false deny without opening the net
False denies happen. IPv6 forms, trailing dots, and bracketed literals are the usual three. Fix the comparer, not the permit. Do not “temporarily” comment out DENY * to see if the demo boots. That is how a weekend API grows a bill.
Work the failure in this order.
- Print the raw host the stack passed into
getaddrinfo. Use a localprint, not a webhook. - Normalize once in
_host_key. Do not add fuzzy suffix matching. Fuzzy matching is howevil.localhost.paid-vendor.examplesneaks through a carelessendswith. - If the app needs an IP literal, list that literal. Do not allow every IPv4 address because one health check used
127.0.0.1. - Re-run the hostile test after every comparer change. A comparer fix that makes
example.comresolve is a regression, not a cleanup.
# labeled debug helper — keep it off by default
DEBUG_EGRESS = False
def decide(host: str) -> str:
key = _host_key(host)
for action, target in RULES:
if target == "*" or target == key:
if DEBUG_EGRESS:
print(f"egress {action} {key} via {target}")
return action
return "DENY"
What this does not cover
The wrapper cannot see a subprocess that shells out to curl. It cannot see a C extension that opens its own sockets. It cannot see raw IPs unless those IPs are listed or denied. It is not a replacement for unshare, a host firewall, or an allowlist at the edge. It also does not prove the handlers are correct. It only proves the process was not allowed to resolve a stranger.
Do not use this approach for payment traffic, health data, or multi-tenant production. Do not use it as evidence that a vibe-coded service is engineered. The permit keeps a demo unbilled. It does not make the demo true.
Solo founders who need guaranteed uptime, questionnaires, or vendor SLAs should pick a paid stack on purpose. Skip the theater of a laptop permit. Everyone else can ship today, accept the limits, and leave the wildcard deny in place.
Top comments (0)