A weekend API stays unbilled only if every new hostname in the diff is named before merge. The gate is static. It reads git diff, writes a receipt, and fails when a paid host, a cloud SDK import, or a secret-shaped environment variable appears without an allow line. Runtime probes help later. They do not replace an inventory of what the model just typed.
Solo founders lose money in the gap between “the agent finished” and “someone read the patch.” That gap is where card processors, telemetry exporters, and managed queues show up as helpful defaults. A one-sitting inventory keeps the bill at zero without pretending the model reviewed itself.
What the receipt must record
The receipt is a JSON file printed in CI and committed next to the patch. Four fields are enough for a solo binary.
-
new_hosts— hostnames and URL schemes found in added lines -
new_imports— top-level names introduced byimport/from -
new_env—os.environkeys and.env-style assignments -
signer— a local identity string the founder types, not a cloud token
Anything else is ceremony. The founder either allows a host in allow_hosts.txt or the merge dies.
Allow list, deny list, decision table
Keep the paid surface in boring text files. Update them when a vendor becomes a real milestone, not when a model suggests one.
# allow_hosts.txt — loopback and docs only
localhost
127.0.0.1
example.com
# deny_hosts.txt — billable by default
api.stripe.com
api.openai.com
*.amazonaws.com
*.googleapis.com
*.azure.com
sentry.io
api.sendgrid.com
api.twilio.com
Wildcards are suffix rules, not DNS. The parser is allowed to be crude. Obfuscated hosts are out of scope for a weekend gate.
| Diff finding | Default | Solo founder action |
|---|---|---|
| Hostname on the deny list | Fail | Revert the hunk or postpone the paid milestone |
| New paid SDK import | Fail | Rewrite against stdlib, SQLite, or a local file |
KEY / TOKEN / SECRET env |
Fail | Keep secrets off the branch |
localhost / 127.0.0.1 URL |
Pass | Still bind loopback in smoke |
Docs host example.com
|
Pass | Do not copy it into runtime config |
The table is the contract. Code below only enforces it.
A parser the founder can read
The script is a single-file gate. It shells out to git diff against main, classifies added lines, and writes RECEIPT.json. Treat it as a local, reproducible example, not a published benchmark.
#!/usr/bin/env python3
"""inventory_diff.py — fail if a git diff invents billable surface."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
HOST_RE = re.compile(
r"(?:https?://|wss://)([A-Za-z0-9.-]+\.[A-Za-z]{2,})",
re.IGNORECASE,
)
BARE_HOST_RE = re.compile(
r"\b([A-Za-z0-9-]+\.(?:com|net|io|ai|cloud|dev))\b",
re.IGNORECASE,
)
IMPORT_RE = re.compile(
r"^\+\s*(?:from|import)\s+([A-Za-z0-9_\.]+)",
re.M,
)
ENV_RE = re.compile(
r"^\+.*(?:environ(?:\[|\.get\()['\"]([A-Z][A-Z0-9_]+))",
re.M,
)
ASSIGN_RE = re.compile(r"^\+([A-Z][A-Z0-9_]{3,})=", re.M)
PAID_IMPORTS = {
"boto3", "stripe", "openai", "anthropic", "sendgrid",
"twilio", "google", "azure", "sentry_sdk",
}
def git_diff() -> str:
base = os.environ.get("INVENTORY_BASE", "main")
proc = subprocess.run(
["git", "diff", "--unified=0", base],
check=True,
capture_output=True,
text=True,
)
return proc.stdout
def load_lines(path: Path) -> list[str]:
if not path.exists():
return []
return [
ln.strip()
for ln in path.read_text().splitlines()
if ln.strip() and not ln.startswith("#")
]
def host_denied(host: str, deny: list[str], allow: list[str]) -> bool:
h = host.lower().rstrip(".")
if h in {a.lower() for a in allow}:
return False
for rule in deny:
rule = rule.lower()
if rule.startswith("*.") and (h.endswith(rule[1:]) or h == rule[2:]):
return True
if h == rule:
return True
return False
def main() -> int:
diff = git_diff()
allow = load_lines(ROOT / "allow_hosts.txt")
deny = load_lines(ROOT / "deny_hosts.txt")
hosts = sorted(set(HOST_RE.findall(diff) + BARE_HOST_RE.findall(diff)))
imports = sorted({m.split(".")[0] for m in IMPORT_RE.findall(diff)})
env_keys = sorted(
set(ENV_RE.findall(diff)) | set(ASSIGN_RE.findall(diff))
)
blocked_hosts = [h for h in hosts if host_denied(h, deny, allow)]
blocked_imports = [i for i in imports if i in PAID_IMPORTS]
secretish = [
e for e in env_keys
if any(s in e for s in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
]
receipt = {
"new_hosts": hosts,
"blocked_hosts": blocked_hosts,
"new_imports": imports,
"blocked_imports": blocked_imports,
"new_env": env_keys,
"secretish_env": secretish,
"signer": os.environ.get("RECEIPT_SIGNER", ""),
}
Path("RECEIPT.json").write_text(json.dumps(receipt, indent=2) + "\n")
print(json.dumps(receipt, indent=2))
errors: list[str] = []
if blocked_hosts:
errors.append(f"billable hosts in diff: {blocked_hosts}")
if blocked_imports:
errors.append(f"paid SDKs in diff: {blocked_imports}")
if secretish:
errors.append(f"secret-shaped env in diff: {secretish}")
if not receipt["signer"]:
errors.append("set RECEIPT_SIGNER to a local name before merge")
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
A pytest wrapper keeps the gate in the command the founder already runs.
# test_inventory.py
import os
import subprocess
import sys
from pathlib import Path
def test_diff_inventory_is_clean():
env = os.environ.copy()
env.setdefault("RECEIPT_SIGNER", "local-dev")
env.setdefault("INVENTORY_BASE", "main")
proc = subprocess.run(
[sys.executable, "inventory_diff.py"],
cwd=Path(__file__).resolve().parent,
env=env,
)
assert proc.returncode == 0, "diff invented billable surface; read RECEIPT.json"
Makefile targets that fit a ship-today loop
Short targets beat a wiki page. One inventory, one smoke, one sign.
.PHONY: inventory smoke sign
INVENTORY_BASE ?= main
RECEIPT_SIGNER ?= $(USER)
inventory:
INVENTORY_BASE=$(INVENTORY_BASE) RECEIPT_SIGNER=$(RECEIPT_SIGNER) python3 inventory_diff.py
smoke: inventory
python3 -m pytest -q test_inventory.py
sign: smoke
@test -n "$(RECEIPT_SIGNER)"
git add RECEIPT.json allow_hosts.txt deny_hosts.txt
git status --short
Commands stay boring on purpose.
git add inventory_diff.py test_inventory.py allow_hosts.txt deny_hosts.txt Makefile
INVENTORY_BASE=main RECEIPT_SIGNER="$USER" python3 inventory_diff.py
make smoke
If the model added import stripe, the process exits 1. That is the product.
Where a free draft loop fits
A solo founder still needs somewhere to let a model write code without parking production keys on the same machine. Free model access and a free server option cover that draft loop while the API is still a loopback service.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode participates only as that draft host. The founder can generate the handler with free model access on the free server option, pull the branch, and run make smoke on the laptop that owns the deny list. The inventory does not run inside the product. It does not need extra hardware. It needs a diff and a signer. Limits apply: this article does not name models, quotas, or uptime, and it does not treat the free option as permanent.
The split is the method. Draft where keys are absent. Inventory where keys still must not appear.
Numbered workflow for one evening
- Branch from
main. Keep.envout of the tree. Export nothing that looks like a vendor token. - Draft the feature on a throwaway remote or a free server session. Accept incomplete types. Reject new dependencies by default.
- Pull the patch onto the laptop that owns
deny_hosts.txt. - Run
INVENTORY_BASE=main RECEIPT_SIGNER="$USER" python3 inventory_diff.py. - Read
RECEIPT.json. Ifblocked_hostsorblocked_importsis non-empty, revert those hunks. Do not allow a cloud SDK just to finish the evening. - Run the existing local smoke with the process bound to
127.0.0.1. Do not open a tunnel. - Commit the receipt with the patch. The receipt is evidence the founder looked. It is not a cryptographic audit.
That sequence ships a weekend binary. It also leaves a paper trail when the founder asks, three weeks later, why the repo still has no processor key.
A quick negative check belongs in the same sitting. Add a throwaway line, run the gate, then drop the line.
echo 'import stripe' >> app.py
python3 inventory_diff.py; echo exit:$?
git checkout -- app.py
Exit code 1 is the expected result. A green run after the revert means the deny list is wired. A green run with stripe still in the tree means the parser never saw the hunk and the gate is lying.
What this does not catch
Regex over a unified diff misses hosts built by concatenation, names hidden in protobuf, and binary assets. It misses a dependency that downloads a paid client at install time. It misses runtime config injected by a platform. Teams that need isolation should use OS network namespaces, egress firewalls, or a CI runner with no route to vendor APIs.
The signer field is social, not cryptographic. Anyone can export RECEIPT_SIGNER. The value is friction for a tired founder, not non-repudiation.
Do not use this gate as the only control on a multi-tenant product, a PCI workflow, or a healthcare API. Those need real policy engines and a named reviewer of record. A deny list in git is a weekend brake. It is not a compliance program.
Founders who already pay for a locked-down preview environment can skip the free draft host. The inventory still applies. The server choice is independent of the receipt.
Accept the limits, then ship
The indie constraint is not elegance. It is a bill that stays at zero until a customer exists. An inventory gate is ugly, local, and fast. It fails closed on text the model added. That is sufficient for a solo API that must leave the laptop this week without learning a paid hostname.
A founder who wants the draft half of this loop without standing up a box can run that branch through MonkeyCode’s free model access and free server option, then inventory the diff before any production secret exists.
Top comments (0)