DEV Community

Dakota Wu
Dakota Wu

Posted on

A Network Tripwire for Zero-Bill Indie MVPs

The cheapest control for an AI-coded weekend product is a failed test. Any patch that opens a real socket should die on the founder’s laptop, and every invented SaaS call should land on an in-process stub. That is the whole method.

Indie backends do not usually burn money on prompts first. They burn it when a generated handler quietly talks to Stripe, Resend, Redis, or a managed queue. The agent is completing a tutorial-shaped pattern. A tripwire treats that pattern as a bug, not as a roadmap.

This workflow is for a solo founder who must ship today and keep the bill at zero. It accepts ugly limits: memory inboxes, codes printed to stdout, SQLite, one process. Those limits are the product constraint, not a prompt to argue with.

Why generated backends grow invoices

Weekend MVPs start as a process and a local database file. Then a coding agent adds “just a webhook” and a hosted mail provider. Those lines look small in a diff. They are not. They demand accounts, API keys, and a public URL the founder does not have before lunch.

Agents also assume retries, queues, and object storage. Each assumption is another vendor dashboard. The waitlist that was supposed to exist by Sunday night becomes four integrations and zero users. Prompts will not hold that line. A test that cannot see the public internet will.

Treat the agent as a fast if-statement with a large training prior. If the prior says “payments belong on a hosted API,” the patch will import that API. The tripwire does not debate the prior. It fails the connect.

The artifact

The artifact is three files in the repo root. No cloud account is required. The loop stays local.

  1. stub_map.json — the only hosts, schemes, and env keys the process may mention.
  2. tripwire.py — a development guard that refuses outbound sockets and records attempts.
  3. test_tripwire.py — a reproducible check run before any AI patch is merged.

Label the snippets below as a starter kit, not as production policy. They are meant to be copied, run, and tightened.

1. Declare the stub map

Create the map first. Empty vendor lists are valid on day one. That emptiness is the point.

{
  "allowed_hosts": ["127.0.0.1", "localhost"],
  "allowed_schemes": ["http"],
  "allowed_env": ["DATABASE_URL", "SECRET_KEY"],
  "stubs": {
    "email": "memory",
    "payments": "memory",
    "queue": "memory",
    "auth_codes": "stdout"
  }
}
Enter fullscreen mode Exit fullscreen mode

email: memory stores messages in a list. auth_codes: stdout prints a one-time login code in the terminal. Neither needs a vendor. Keep the file in git. A patch that adds STRIPE_SECRET_KEY without updating this map should fail in step 4.

2. Install the tripwire

The tripwire wraps socket.socket.connect. It is a fail-fast for local tests. It is not a sandbox, a firewall, or a compliance control.

# tripwire.py
from __future__ import annotations

import json
import socket
from pathlib import Path

MAP_PATH = Path(__file__).resolve().parent / "stub_map.json"
_map = json.loads(MAP_PATH.read_text())
ALLOWED = set(_map.get("allowed_hosts") or [])
LOG: list[str] = []

_real_connect = socket.socket.connect

def _guarded_connect(self, address):
    host = address[0] if isinstance(address, tuple) else address
    host = str(host)
    if host not in ALLOWED:
        LOG.append(host)
        raise RuntimeError(
            f"tripwire: blocked outbound connect to {host!r}. "
            "Add an in-process stub or update stub_map.json."
        )
    return _real_connect(self, address)

socket.socket.connect = _guarded_connect  # type: ignore[method-assign]
Enter fullscreen mode Exit fullscreen mode

Load it before application code:

export PYTHONPATH=.
python -c "import tripwire; import app"
Enter fullscreen mode Exit fullscreen mode

In tests, import the guard first:

import tripwire  # noqa: F401  — must import before app code
Enter fullscreen mode Exit fullscreen mode

Short rule: if the test process can reach the public internet, the tripwire is not installed. Fix the import order before debugging the app.

3. Provide in-process stubs

Stubs should be boring. They exist so the agent can keep writing handlers without opening accounts. Wire them in one module. Do not let route files construct SDK clients.

# stubs.py
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone

@dataclass
class MemoryMailbox:
    messages: list[dict] = field(default_factory=list)

    def send(self, to: str, subject: str, body: str) -> dict:
        item = {
            "to": to,
            "subject": subject,
            "body": body,
            "sent_at": datetime.now(timezone.utc).isoformat(),
        }
        self.messages.append(item)
        return item

@dataclass
class MemoryLedger:
    charges: list[dict] = field(default_factory=list)

    def charge(self, amount_cents: int, currency: str = "usd") -> dict:
        if amount_cents <= 0:
            raise ValueError("amount_cents must be positive")
        item = {
            "id": f"stub_{len(self.charges) + 1}",
            "amount_cents": amount_cents,
            "currency": currency,
            "status": "stubbed",
        }
        self.charges.append(item)
        return item

@dataclass
class StdoutCodes:
    def issue(self, email: str) -> str:
        code = f"{abs(hash(email)) % 10**6:06d}"
        print(f"[auth-stub] {email} -> {code}")
        return code
Enter fullscreen mode Exit fullscreen mode
# wiring.py
from stubs import MemoryMailbox, MemoryLedger, StdoutCodes

mailbox = MemoryMailbox()
ledger = MemoryLedger()
codes = StdoutCodes()
Enter fullscreen mode Exit fullscreen mode

A route calls mailbox.send(...). The generated code still looks like a product. The invoice does not exist. That trade is the indie default until someone pays.

4. Run the test plan

The test plan is the merge gate. Run it on every AI patch, including the manual edits that follow a stalled agent.

# test_tripwire.py
import json
import re
from pathlib import Path

import pytest

import tripwire  # noqa: F401
from stubs import MemoryMailbox, MemoryLedger

ROOT = Path(__file__).resolve().parent
MAP = json.loads((ROOT / "stub_map.json").read_text())
ENV_RE = re.compile(
    r"os\.environ\[(['\"])([A-Z0-9_]+)\1\]"
    r"|os\.getenv\((['\"])([A-Z0-9_]+)\3"
)

def test_stub_map_lists_only_local_hosts():
    for host in MAP["allowed_hosts"]:
        assert host in {"127.0.0.1", "localhost", "::1"}

def test_tripwire_blocks_public_dns():
    import socket

    with pytest.raises(RuntimeError, match="tripwire"):
        socket.socket().connect(("example.com", 443))

def test_mailbox_does_not_touch_network():
    box = MemoryMailbox()
    box.send("founder@localhost", "hello", "ship")
    assert box.messages[0]["to"] == "founder@localhost"

def test_ledger_rejects_zero():
    with pytest.raises(ValueError):
        MemoryLedger().charge(0)

def test_source_env_keys_are_declared():
    allowed = set(MAP["allowed_env"])
    undeclared: set[str] = set()
    for path in ROOT.rglob("*.py"):
        if path.name.startswith("test_") or path.name == "tripwire.py":
            continue
        text = path.read_text(encoding="utf-8")
        for match in ENV_RE.finditer(text):
            key = match.group(2) or match.group(4)
            if key and key not in allowed:
                undeclared.add(f"{path.name}:{key}")
    assert not undeclared, f"undeclared env keys: {sorted(undeclared)}"
Enter fullscreen mode Exit fullscreen mode

Commands:

python -m pip install pytest
python -m pytest test_tripwire.py -q
Enter fullscreen mode Exit fullscreen mode

Expected result on a clean stubbed app: all tests pass. After an agent adds os.environ["STRIPE_SECRET_KEY"], the env test fails. After a code path calls a public host, the connect test fails if that path runs under the guard.

Grep is not a parser. Dynamic os.environ.get(name) can slip through. Keep the live tripwire as the backstop when the handler actually executes.

5. Debug a red patch in order

When the gate fails, do not start by asking the model to “fix CI.” Walk the failure in this order.

  1. Read the traceback. A tripwire: blocked outbound connect line names the host. That host is the invented vendor.
  2. Search the diff for SDKs and URLs, not for architecture essays.
  3. Replace the call with a function on stubs.py. Keep the route signature stable so the agent does not rewrite the rest of the file.
  4. If a new env key is required for a later paid phase, do not add it now. Add a comment in stub_map.json only when revenue justifies the vendor.
  5. Re-run python -m pytest test_tripwire.py -q until it is green. Then merge.

A useful extra check is a model reading the diff against the map. Static tests miss a TODO that says “switch this mailbox to a hosted provider after launch” sitting next to half-wired client code. MonkeyCode’s free model access and free server option can run that narrow pass without adding a second vendor bill. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Keep the review prompt as a checklist:

Read the diff and stub_map.json.
Fail if the patch:
- imports a payments, email, SMS, queue, or object-storage SDK
- adds env keys not listed in allowed_env
- documents a hosted provider as a follow-up in the same files as runtime code
Pass only if new I/O uses stubs.py or localhost.
Enter fullscreen mode Exit fullscreen mode

Keep secrets off that server. The stub map and the diff are enough. API keys should not exist yet. Availability of free model access and a free server can change, so do not plan capacity around them.

Decision table

Signal in the patch Day-one action Later, with revenue
Email SDK MemoryMailbox One provider, one env key, update the map
Payments SDK MemoryLedger with status: stubbed Real processor behind the same charge()
Redis / queue In-process list Only after a measured backlog
Auth email StdoutCodes Email provider, still one issue()
Object storage Local ./data directory Bucket only for real user uploads
New env key Fail the test Add to allowed_env with a comment

Ship the left column today. The right column is a product decision. It is not an agent default.

Limitations

The tripwire patches CPython socket.socket.connect. It will not wrap every HTTP stack on every interpreter. Native extensions, subprocesses, and os.system("curl ...") bypass it. A founder who needs a hard network namespace should use OS-level isolation, not this module.

The env scanner is a regular expression. Obfuscated access wins. Teams that need a real policy engine should parse AST or run the app inside a network jail.

Stubs lie. A memory mailbox cannot teach deliverability. A stub charge cannot teach disputes or refunds. That is acceptable for a waitlist. It is not acceptable for money movement.

Free model access and a free server do not make the tripwire stronger. They only add a second reader for the diff. Do not treat them as a quota, a hardware spec, or a permanent plan.

Who should not use this

Skip the tripwire if the product already depends on a paid provider. Skip it for regulated workloads, production incident response, and anything that must send a real message today.

Skip it if failing tests will be ignored. A red suite that always gets -k is theater. Multi-service staging clusters do not belong here either. This workflow assumes one process, one developer, and a bill that must stay at zero until a user pays.

The original problem remains: agents complete vendor-shaped code. The tripwire makes that completion expensive in minutes, not in dollars. Minutes are the resource a solo founder can spend before lunch.

Top comments (0)