From Python, one function at a time
You do not have to rewrite a Python program to get a Capa authority manifest. Wrap the Python file in a thin Capa shell that delegates everything, then move one function at a time into typed Capa. At every step the Unsafe capability shrinks and the per-function manifest gets more honest. The Python file never changes; only the .capa file does.
A manifest Python cannot give you
Capa programs declare authority in signatures: a function that opens a socket takes Net; one that reads a file takes Fs; one that does neither takes neither. pip freeze lists packages, not functions; a static analyser is heuristic and bottoms out at the import boundary. The cheapest path to an auditable authority surface is not a rewrite: keep the Python intact, build a thin Capa shell, and migrate function by function.
The running example is migrate_logfetcher_naive.py, a ~60-line program touching Fs, Env and Net, paired with three .capa files showing three stages of hardening. All four live in the examples directory.
Three stages
All Unsafe, behaviour preserved
One entry point imports the original Python module and calls into it. Everything happens through py_import / py_invoke, which require Unsafe.
fun main(stdio: Stdio, u: Unsafe)
bootstrap_path(u)
let mod = py_import(u, "migrate_logfetcher_naive")
py_invoke(u, mod.main, [])bootstrap_path -> [Unsafe]
main -> [Stdio, Unsafe]The Unsafe is the audit signal: this program escapes Capa's analysis, so I cannot claim anything about its true authority surface. Honest reporting of the not-yet-migrated state.
Move one function at a time
Pick the simplest function, here save_response: two strings in, one file written, mapping cleanly to Fs.write.
fun save_response(fs: Fs, path: String, content: String) -> Result<Unit, IoError>
return fs.write(path, content)bootstrap_path -> [Unsafe]
save_response -> [Fs] # new, typed, no Unsafe
main -> [Stdio, Fs, Unsafe] # Fs now visibleThe win is Fs becoming explicit in main's signature: the SBOM consumer sees the file-write authority is exercised by a typed function, not buried inside an Unsafe block.
Fully typed, Unsafe gone
Once the last py_invoke is gone, the Python file is unreferenced and can be deleted. main threads the exact capabilities the program uses and nothing more.
config_field -> [] # pure
load_config -> [Fs]
get_api_key -> [Env]
build_url -> [] # pure
fetch_status -> [Net]
save_response -> [Fs]
main -> [Stdio, Fs, Env, Net] # no UnsafeCompare to stage 1, where the only honest thing the manifest could say about main was [Stdio, Unsafe]. The SBOM is now a true per-function authority bound rather than a single Unsafe blob.
capa migrate reports it for you
You do not have to read manifests by eye. capa migrate <file.capa> reports the share of functions that no longer touch Unsafe, the removable Unsafe parameters, and the cheapest next candidates.
Migration progress for examples/migrate_logfetcher_step2_mixed.capa
[########----------------] 33% Unsafe-free
1/3 function(s) are Unsafe-free; 2 still use Unsafe.
Next, consider hardening (fewest bridge calls first):
- bootstrap_path ...:26:1 (5 bridge calls)
- main ...:39:1 (9 bridge calls)Add --json for the machine-readable form, useful in a CI gate that watches the percentage trend upward.
What migration does and does not buy you
- Capa's built-in capability surface is narrow. If your Python uses requests with custom headers, gRPC, or sqlite3, that function stays on the Unsafe side. The story bottoms out at what the standard library covers.
- The Python file is not safer because of the migration. It still has ambient authority; the Capa side is the audit surface. When migration completes, you delete the Python file.
- Runtime. The default backend transpiles to Python (1.00x to 1.45x overhead). The Wasm Component Model backend is also functional, but Unsafe is rejected at Wasm emit time, so the all-Unsafe stages run on Python; the typed end state runs on either.
- Stop sooner if you like. If one function genuinely needs a Python library Capa has no built-in for, leave that one with Unsafe: it becomes a precise audit signal pointing at the one place that needs human review.
The three files type-check and emit honest manifests
# Each stage type-checks
$ capa --check examples/migrate_logfetcher_step1_unsafe.capa
$ capa --check examples/migrate_logfetcher_step2_mixed.capa
$ capa --check examples/migrate_logfetcher_step3_typed.capa
# Manifest progression: Unsafe shrinks, real capabilities appear
$ capa --manifest examples/migrate_logfetcher_step3_typed.capa \
| jq '.functions[] | {name, declared_capabilities}'The full writeup is in docs/migration.md.