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
- Create
app/with one process, one SQLite file, and no package installer step beyond the language runtime. - Commit
import_allowlist.jsonbefore any generated routes exist. Hash it. Do not let the agent own that file. - Put
scan_imports.pyon the same commit. Wiremake gateso local runs and CI share one command. - Generate or paste handlers only under
app/. If a patch needsboto3, the build goes red and the founder decides in daylight. - Add a pytest that shells out to the scanner. Do not re-implement the rules in the test. One implementation stays honest.
- 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.
Top comments (0)