Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 8 min read

Pin the Runtime Imports Before an Agent Adds a Paid Client

A weekend API stays at zero bill when the process cannot import a paid client. Language models still reach for redis, boto3, stripe, and celery because those names look like β€œproduction.” An import allowlist, hashed in g

A weekend API stays at zero bill when the process cannot import a paid client. Language models still reach for redis, boto3, stripe, and celery because those names look like β€œproduction.” An import allowlist, hashed in git and failed in CI, blocks that class of patch before a card is attached.

Solo founders do not need a mesh. They need one file an agent cannot enlarge without a human. The rest of this article is a worked example: a JSON allowlist, an AST scanner, a stdlib HTTP server on SQLite, and a pytest that treats a new top-level import as a ship-blocker. It is a layout, not a measured production run.

The failure mode that lockfiles miss

A frozen pyproject.toml stops silent version drift. It does not stop an agent from adding a package and then β€œhelpfully” updating the lockfile in the same patch. The bill appears later, when the new client opens a socket to a hosted broker, object store, or payment API.

Runtime imports are the narrower contract. If app/ may import sqlite3 and http.server and may not import redis, the process cannot grow a second datastore by accident. Dependencies can stay larger than the allowlist. The allowlist is the thing a human reviews.

This gate does not claim to see every side channel. Dynamic imports, C extensions, and raw HTTP remain out of scope. Those need other controls. The point here is cheaper: make the obvious paid SDK a red build.

Artifact: a hashed allowlist

Keep the contract in import_allowlist.json at the repo root. Start from the standard library plus the app package. Add a third-party root only when the founder already runs it on the free machine.

{
  "modules": [
    "asyncio",
    "collections",
    "contextlib",
    "datetime",
    "hashlib",
    "hmac",
    "http",
    "json",
    "logging",
    "os",
    "pathlib",
    "secrets",
    "sqlite3",
    "sys",
    "time",
    "typing",
    "uuid",
    "app"
  ],
  "scan": ["app"],
  "ban_dynamic": ["importlib", "runpy"]
}

The ban_dynamic list is intentional. importlib.import_module("stripe") would otherwise walk around the allowlist. The scanner below treats those roots as violations even if they appear in modules by mistake. A founder who truly needs them should delete the ban in a reviewed commit, not in an agent loop.

Scanner the CI can run cold

The following script is a complete, stdlib-only checker. Save it as scan_imports.py. It walks AST nodes, records top-level module roots, and exits 1 on extras.

#!/usr/bin/env python3
"""Fail CI when app code imports a root outside import_allowlist.json."""
from __future__ import annotations

import ast
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
ALLOW = json.loads((ROOT / "import_allowlist.json").read_text())
ALLOWED = set(ALLOW["modules"])
BANNED = set(ALLOW.get("ban_dynamic", []))
SCAN = ALLOW.get("scan", ["app"])
SKIP_DIR = {".git", ".venv", "venv", "__pycache__", "node_modules"}


def root_name(mod: str) -> str:
    return mod.split(".")[0]


def file_imports(path: Path) -> set[str]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    found: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                found.add(root_name(alias.name))
        elif isinstance(node, ast.ImportFrom) and node.module:
            found.add(root_name(node.module))
        elif isinstance(node, ast.Call):
            func = node.func
            if isinstance(func, ast.Name) and func.id == "__import__":
                found.add("__import__")
    return found


def py_files(base: Path):
    for path in base.rglob("*.py"):
        if any(part in SKIP_DIR for part in path.parts):
            continue
        yield path


def main() -> int:
    violations: list[str] = []
    for rel in SCAN:
        base = ROOT / rel
        if not base.exists():
            print(f"missing scan dir: {rel}", file=sys.stderr)
            return 2
        for path in py_files(base):
            names = file_imports(path)
            extra = (names - ALLOWED) | (names & BANNED) | (names & {"__import__"})
            for name in sorted(extra):
                loc = path.relative_to(ROOT)
                violations.append(f"{loc}: disallowed import root `{name}`")
    if violations:
        print("import allowlist violations:")
        print("\n".join(violations))
        return 1
    print(f"ok: {len(ALLOWED)} allowed roots, scan={SCAN}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Run it with python3 scan_imports.py. No extra packages. No container. A dirty agent patch that inserts import redis fails in under a second.

Numbered workflow for a solo ship week

  1. Create app/ with one process, one SQLite file, and no package installer step beyond the language runtime.
  2. Commit import_allowlist.json before any generated routes exist. Hash it. Do not let the agent own that file.
  3. Put scan_imports.py on the same commit. Wire make gate so local runs and CI share one command.
  4. Generate or paste handlers only under app/. If a patch needs boto3, the build goes red and the founder decides in daylight.
  5. Add a pytest that shells out to the scanner. Do not re-implement the rules in the test. One implementation stays honest.
  6. After a human merge, freeze the allowlist again. New roots are a product decision, not a refactor leftover.

The order matters. An allowlist written after the agent has already imported a queue client is a changelog, not a gate.

A stdlib server that should pass

The server below is labeled as an example. It listens on one port, stores rows in ./data/app.sqlite3, and never leaves the standard library. That is the ship-today shape for an indie MVP that can wait on email, search, and cards.

# app/server.py
from __future__ import annotations

import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

DB = Path(__file__).resolve().parent.parent / "data" / "app.sqlite3"


def connect() -> sqlite3.Connection:
    DB.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)"
    )
    conn.commit()
    return conn


class Handler(BaseHTTPRequestHandler):
    def _send(self, code: int, payload: dict) -> None:
        raw = json.dumps(payload).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_GET(self) -> None:  # noqa: N802
        if self.path != "/notes":
            self._send(404, {"error": "not found"})
            return
        conn = connect()
        rows = conn.execute("SELECT id, body FROM notes ORDER BY id").fetchall()
        conn.close()
        self._send(200, {"notes": [{"id": i, "body": b} for i, b in rows]})

    def do_POST(self) -> None:  # noqa: N802
        if self.path != "/notes":
            self._send(404, {"error": "not found"})
            return
        length = int(self.headers.get("Content-Length", "0"))
        if length <= 0 or length > 4096:
            self._send(413, {"error": "body limit"})
            return
        data = json.loads(self.rfile.read(length))
        body = str(data.get("body", "")).strip()
        if not body:
            self._send(400, {"error": "empty"})
            return
        conn = connect()
        cur = conn.execute("INSERT INTO notes(body) VALUES (?)", (body,))
        conn.commit()
        note_id = cur.lastrowid
        conn.close()
        self._send(201, {"id": note_id})


def main() -> None:
    server = ThreadingHTTPServer(("127.0.0.1", 8080), Handler)
    server.serve_forever()


if __name__ == "__main__":
    main()

A patch that β€œimproves” this with import stripe is a failed make gate, not a debate in a chat log. The founder can still add billing later. The difference is a reviewed allowlist edit, not an unattended Friday merge.

Makefile and pytest

.PHONY: gate test

gate:
    python3 scan_imports.py

test: gate
    python3 -m pytest -q
# tests/test_import_allowlist.py
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def test_import_allowlist_clean():
    proc = subprocess.run(
        [sys.executable, str(ROOT / "scan_imports.py")],
        cwd=ROOT,
        capture_output=True,
        text=True,
        check=False,
    )
    assert proc.returncode == 0, proc.stdout + proc.stderr

pytest is the only likely extra tool. If even that is too much for a given weekend, make gate alone is enough to reject a bad import graph. Add the test when the repo grows a second contributor or a second machine.

Where a free coding loop still fits

The allowlist is the product of this workflow. An agent is optional filling for app/ after the contract exists. 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, which can draft handlers against this layout without opening a cloud bill for the coding loop itself. The scanner still runs on the founder’s laptop. A hosted session that cannot pass scan_imports.py is not a release session.

Keep the allowlist file read-only in the agent’s instructions. If the model rewrites JSON to include celery, that is the same class of failure as rewriting production Terraform. Humans own the contract. Models own the boring handlers inside it.

Limits that should stay visible

AST misses importlib.import_module if the scanner is weakened, and it misses __import__ if the Call-walker is deleted. It does not see imports inside eval, extension modules, or a subprocess that runs python -c. It does not block urllib.request from posting to a paid API using only stdlib. Founders who need that layer already know they want an outbound host allowlist, which is a different artifact.

The JSON file also cannot encode intent. Allowing http permits both the local server and a future outbound client. If that becomes a problem, split the app into app/http_server.py and deny other files from importing urllib or http.client. Do not pretend one list replaces design.

SQLite will not survive every workload. Multi-writer analytics, regional failover, and compliance export jobs outgrow a single file. The allowlist then changes in a priced milestone, not in a vibe pass.

Who should not use this

Teams that already run Redis, Postgres, and a worker fleet should not shrink their import graph to prove a point. The gate is for a solo founder who can ship a notes API, a waitlist, or a file-upload toy this week and can accept missing search, mail, and cards.

Anyone selling a multi-tenant billing surface this month should design the paid client in the open, with a human-owned commit that adds stripe to the allowlist on purpose. Hiding that import behind an agent is how surprise invoices start.

Regulated workloads that require managed identity, hosted audit logs, or vendor BAAs are outside this pattern. Stdlib HTTP on loopback is not a control framework.

Close the loop

Ship the scanner first. Generate routes second. Add paid roots only when a real invoice is an acceptable outcome. The import graph is small enough to read. That is the entire advantage a solo backend still has.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.