Migrating

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.

Why migrate

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.

The progression

Three stages

01

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.

step1_unsafe.capa
fun main(stdio: Stdio, u: Unsafe)
    bootstrap_path(u)
    let mod = py_import(u, "migrate_logfetcher_naive")
    py_invoke(u, mod.main, [])
capa --manifest
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.

02

Move one function at a time

Pick the simplest function, here save_response: two strings in, one file written, mapping cleanly to Fs.write.

step2_mixed.capa
fun save_response(fs: Fs, path: String, content: String) -> Result<Unit, IoError>
    return fs.write(path, content)
capa --manifest
bootstrap_path -> [Unsafe]
save_response  -> [Fs]                  # new, typed, no Unsafe
main           -> [Stdio, Fs, Unsafe]   # Fs now visible

The 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.

03

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.

capa --manifest
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 Unsafe

Compare 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.

Track your progress

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.

capa migrate step2
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.

Honest limits

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.
Reproduce it

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.