Your Clone Throws KeyError. Harvest getenv From the AST.
You cloned the branch. You typed the command that looked identical to the agent's session. The interpreter died on KeyError: 'DATABASE_URL'. Your Python install is not the bug. The remote process started because its env
You cloned the branch. You typed the command that looked identical to the agent's session. The interpreter died on KeyError: 'DATABASE_URL'.
Your Python install is not the bug. The remote process started because its environment already held names nobody wrote into the tree. Your checkout has the code and no config contract.
This is a harvest. It is not a secret dump. You pull literal names from source, classify them, and rebuild .env.example without copying a single value from the scratch box.
The boot failure you just cloned
Watch the first half minute of a local start.
git switch agent/add-pagination
python -m app
You might get a raw KeyError. You might get a settings class complaining that REDIS_URL is missing. You might get Compose interpolating an empty ${SMTP_HOST} and then failing later, in a worker, where the stack trace is less kind.
The agent did not forget how to write Python. It treated a living process environment as if it were a file. A scratch machine can hold leftovers from your own export, from a compose file that never got committed, or from a previous run that "fixed" boot by mutating the shell.
Why a remote boot is a bad spec
Scratch machines accumulate state. You export a database URL to try one query. The model retries. The app boots. Nobody adds the name to .env.example.
Copying printenv is worse than writing nothing. That output can contain tokens. It can contain host-only paths. It can contain values that work solely on that box.
You need names, requiredness, and defaults. You do not need the scratch box's secrets.
A cheap first boot still has a job. MonkeyCode offers free model access and a free server option. Use that pair to reproduce a traceback, not to freeze a living environment as documentation.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If the session is still up, list names only. Then throw the session away and rebuild the contract from the tree.
Three files that must agree
Put these three artifacts on one screen.
-
Code reads — every literal
os.getenv,os.environ[...], andos.environ.get. -
Example file —
.env.exampleorenv.sample, placeholders only. -
Orchestration —
docker-compose.yml, acompose.yaml, or a shell wrapper that interpolates${NAME}.
A name in (1) but not in (2) means clones fail. A name in (2) but not in (1) means you ship folklore. A name in (3) with a different spelling means you will debug REDIS_URL versus REDIS_URI for an afternoon.
The rest of this article makes that diff mechanical.
Harvest, classify, write
Do the steps in this order. Do not invert them.
- Freeze the branch.
git status --shortshould show only files you intend to keep. - Run the harvester below at the service root. Save
harvest.json. - Read
missing_from_exampleandexample_onlybefore you open an editor. - Classify each name with the decision table.
- Write
.env.examplewith empty or dummy values. Never with live secrets. - Copy it to a gitignored
.env, fill local values by hand, and start the app. - Delete any
printenvcapture you took during the scratch session.
Skip step 7 and you will paste a password into a prompt later. Do not skip it.
A proposed AST harvester
Label this as a proposed script. It is not a vault. It is not a twelve-factor linter. It walks Python files for literal environment keys, then heuristically scans compose and shell files for ${NAME} and export NAME=.
Save it as scripts/harvest_env.py.
#!/usr/bin/env python3
"""Proposed env-name harvester. Review output before you commit anything."""
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "dist"}
COMPOSE_NAME = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(?::-?[^}]*)?\}")
ASSIGN_EXPORT = re.compile(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)=", re.M)
class EnvVisitor(ast.NodeVisitor):
def __init__(self, rel: str) -> None:
self.rel = rel
self.hits: list[dict] = []
def _const(self, node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def visit_Subscript(self, node: ast.Subscript) -> None:
value = node.value
sl = node.slice
if (
isinstance(value, ast.Attribute)
and value.attr == "environ"
and isinstance(sl, ast.Constant)
and isinstance(sl.value, str)
):
self.hits.append(
{
"name": sl.value,
"method": "os.environ[]",
"default": None,
"file": self.rel,
"line": node.lineno,
}
)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
func = node.func
name = None
default = None
method = None
if isinstance(func, ast.Attribute) and func.attr == "getenv":
method = "os.getenv"
if node.args:
name = self._const(node.args[0])
if len(node.args) > 1:
default = self._const(node.args[1])
for kw in node.keywords:
if kw.arg == "key":
name = self._const(kw.value)
if kw.arg == "default":
default = self._const(kw.value)
if (
isinstance(func, ast.Attribute)
and func.attr == "get"
and isinstance(func.value, ast.Attribute)
and func.value.attr == "environ"
):
method = "os.environ.get"
if node.args:
name = self._const(node.args[0])
if len(node.args) > 1:
default = self._const(node.args[1])
if name:
self.hits.append(
{
"name": name,
"method": method,
"default": default,
"file": self.rel,
"line": node.lineno,
}
)
self.generic_visit(node)
def iter_py(root: Path):
for path in root.rglob("*.py"):
if any(part in SKIP_DIRS for part in path.parts):
continue
yield path
def harvest_python(root: Path) -> list[dict]:
hits: list[dict] = []
for path in iter_py(root):
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (SyntaxError, UnicodeDecodeError):
continue
visitor = EnvVisitor(str(path.relative_to(root)))
visitor.visit(tree)
hits.extend(visitor.hits)
return hits
def harvest_compose_and_shell(root: Path) -> list[dict]:
hits: list[dict] = []
files: list[Path] = []
for pat in ("docker-compose*.yml", "compose*.yml", "compose*.yaml", "*.sh"):
files.extend(root.rglob(pat))
for path in files:
if any(part in SKIP_DIRS for part in path.parts):
continue
text = path.read_text(encoding="utf-8", errors="replace")
rel = str(path.relative_to(root))
for match in COMPOSE_NAME.finditer(text):
hits.append(
{
"name": match.group(1),
"method": "compose-or-shell-interpolation",
"default": None,
"file": rel,
"line": text[: match.start()].count("\n") + 1,
}
)
if path.suffix == ".sh":
for match in ASSIGN_EXPORT.finditer(text):
hits.append(
{
"name": match.group(1),
"method": "shell-assign",
"default": None,
"file": rel,
"line": text[: match.start()].count("\n") + 1,
}
)
return hits
def load_example(root: Path) -> set[str]:
names: set[str] = set()
for candidate in (".env.example", "env.sample", ".env.sample"):
path = root / candidate
if not path.is_file():
continue
for line in path.read_text(encoding="utf-8").splitlines():
raw = line.strip()
if not raw or raw.startswith("#") or "=" not in raw:
continue
names.add(raw.split("=", 1)[0].strip())
return names
def main() -> None:
root = Path.cwd()
hits = harvest_python(root) + harvest_compose_and_shell(root)
example = load_example(root)
code_names = {h["name"] for h in hits}
report = {
"cwd": str(root),
"names": sorted(code_names),
"missing_from_example": sorted(n for n in code_names if n not in example),
"example_only": sorted(n for n in example if n not in code_names),
"hits": hits,
"note": "names only; never copy values from a scratch process",
}
Path("harvest.json").write_text(json.dumps(report, indent=2) + "\n")
print(f"wrote harvest.json with {len(code_names)} names")
print("missing_from_example:", ", ".join(report["missing_from_example"]) or "(none)")
print("example_only:", ", ".join(report["example_only"]) or "(none)")
if __name__ == "__main__":
main()
Run it at the service root, not from a nested package directory.
python scripts/harvest_env.py
python -m json.tool harvest.json | less
You now have a list of names. You do not have a closed set. os.getenv(prefix + suffix) will not appear. Neither will a Pydantic field that never calls os.environ in your tree.
Classify each name before you type a value
Use this table in review. Most bad .env.example files are classification errors, not typing errors.
| Signal | What it means | Safe next action | Unsafe next action |
|---|---|---|---|
Literal os.environ["NAME"] and no default |
Boot throws without it | Add NAME= to .env.example
|
Paste the scratch value into git |
os.getenv("NAME", "localhost") |
Code already has a fallback | Document the default in a comment | Ship localhost to production by accident |
| Name in compose interpolation only | Orchestration expects it | Align compose spelling with the code | Rename in one file and not the other |
Name in .env.example only |
Folklore or dead config | Delete it or find the real read | Keep it "in case" |
Name looks like a secret (*_KEY, *_TOKEN, PASSWORD) |
Value must not enter git | Placeholder only; rotate if it leaked | Commit a "dev" password |
| App still reads a name the harvester missed | Dynamic lookup or another language | Grep, then add a human note | Treat harvest.json as complete |
Read the secret row twice. Values that lived on a scratch box are compromised for documentation purposes even if they "worked."
Watch spelling drift in orchestration. This fragment looks harmless.
# Proposed compose fragment. Spellings must match the code.
services:
api:
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
If the Python reads REDIS_URI, both names show up in harvest.json. Your clone can still fail. Diff the name list, do not skim the YAML.
Write the example file by hand
Do not ask a model to invent a .env from a remote printenv. You already have names. Fill placeholders yourself.
python - <<'PY'
import json
from pathlib import Path
data = json.loads(Path("harvest.json").read_text())
lines = ["# Skeleton only. Comments belong above secrets. Never paste live values."]
for name in data["names"]:
lines.append(f"{name}=")
Path(".env.example").write_text("\n".join(lines) + "\n")
print("wrote .env.example skeleton")
PY
Then edit the skeleton. Put a one-line comment above secrets describing how a human obtains them. Keep public defaults in comments, not as values that look real.
# Required. Point at a local compose service, not at the scratch box.
DATABASE_URL=
# Optional. Code falls back to localhost:6379 if empty.
REDIS_URL=
# Required for password-reset mail. Leave empty to disable the route in dev.
SMTP_HOST=
SMTP_PASSWORD=
Start with a gitignored .env copied from that file. Fill local values by hand.
cp .env.example .env
# edit .env with local placeholders you generated yourself
python -m app
If it still throws, the visitor missed a dynamic key. Grep next. Do not open printenv.
grep -RInE 'os\.environ|os\.getenv|process\.env' \
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=node_modules \
--exclude=harvest.json
Add the missed name to .env.example. Re-run the app. Repeat until a cold clone path boots with only documented names.
Keep the scratch shell out of the contract
You can still use a remote shell to watch a traceback. You cannot use it as the source of truth for config.
If the box is still alive and you must compare names, print names only:
# names only; discard the stream after you diff
env | cut -d= -f1 | sort > /tmp/scratch-env-names.txt
python -c 'import json; print("\n".join(json.load(open("harvest.json"))["names"]))' \
| sort > /tmp/code-env-names.txt
diff -u /tmp/code-env-names.txt /tmp/scratch-env-names.txt
rm -f /tmp/scratch-env-names.txt
Extra names on the process are leftovers or libraries you have not documented. Missing names mean the remote boot leaned on defaults you will not want in production.
Never redirect printenv into the repo. Never paste values back into a model prompt so it can "write my .env."
Where this method breaks
The visitor does not understand os.environ.get(key) when key is a variable. It does not read Kubernetes generators. It does not invent Pydantic BaseSettings field names unless those fields also touch os.environ in your tree. It does not see variables consumed only inside a third-party library.
Makefiles and non-Python services will be undercounted unless you extend the regex pass. JSON output looks authoritative. It is a draft inventory.
Who should not use this approach?
- Services that load every setting from a runtime vault with no literals in code
- Monorepos where Python is a side language and the real reads live in Go or Rust
- Pipelines that would upload
harvest.jsonnext to a live.env - Anyone hoping a remote
envlisting will satisfy an audit
Rotate anything that was printed in an agent log. The harvest does not rotate secrets for you.
After the clone boots
Commit .env.example and, if your team wants the check in review, the harvester. Do not commit .env. The sample script stores relative paths, which is usually fine to keep in harvest.json during the review, then delete.
A reviewer should be able to clone, copy the example file, fill a few local values, and start the app. If they cannot, the contract is still wrong. When a remote boot works and a local clone throws, run the harvester before you ask a model to invent a .env.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.