Alpha status. The propose → verify → write-back loop, the offline, OpenRouter/Kimi, OpenAI/Codex, and Anthropic backends, and the verifier gate are implemented and running. The concurrency and type/struct roles are implemented as deterministic, zero-dependency analyzer passes (see Specialized analyzers below) and now run inside the call-graph crew as autonomous proposers that emit conservative, economy-gated names and notes for the namer. The remaining specialized agent roles (Oracle adjudicator, diff, behavioral verifier) are the intended next steps; the naming crew currently runs a single generic backend pass alongside these specialist proposers.
The core loop: propose → verify → write-back
run_agent_pass in src/warden/agents/crew.py drives one full sweep over a module version.
1
Gather hard facts
For every defined function in the version,
gather_facts assembles a FunctionFacts
object (the hallucination constraint). Every field is derived mechanically from the binary
and the KB; the backend sees only what is actually in the binary. See FunctionFacts below.2
Sort bottom-up
Functions are sorted by number of call targets, ascending. Leaf functions run first. As
callees acquire names, the next caller’s context is richer when the backend sees it.
3
Skip already-confident entries
If a function already has a symbol with
confidence >= 0.5 (the SKIP_CONFIDENCE
constant in crew.py), or if the symbol is locked (human or Oracle), the function is
skipped. This is the mechanism that makes re-running safe: the crew never overwrites
high-confidence or locked work.4
Backend proposes
The selected backend receives the
FunctionFacts and returns a Proposal (a name,
summary, confidence score, and optional refined type signature), or None to abstain.5
Verifier gate
verify_proposal runs cheap sanity checks before anything reaches the KB. It rejects
proposals with invalid identifiers, names shorter than two characters, confidence outside
[0, 1], or string-xref claims that aren’t backed by actual referenced strings. See
the verifier gate for detail.6
Write-back under the economy
Accepted proposals are submitted to
kb.upsert_symbol with provenance="agent". The
KB’s economy decides whether to actually write: an agent proposal may only overwrite a
lower-confidence prior agent entry. Human, Oracle, and higher-confidence agent entries are
never touched. Each write stores the provenance trail and evidence list alongside the
symbol.run_agent_pass returns an AgentRunResult with counters for considered,
proposed, written, rejected_by_verifier, rejected_by_economy, and skipped_existing. The
call-graph strategy adds specialist_proposed and specialist_written (the specialist proposals
that went through the verifier and economy) and rounds_run (how many bottom-up rounds actually
ran before a fixpoint).
FunctionFacts: the hallucination constraint
referenced_strings does not contain.
referenced_stringsis built by walking the function’si32.constinstructions and looking up each immediate in the module’s data-section string map.call_targetscontains direct call names (with imports resolved to their import names) and<indirect>forcall_indirectsites.type_signatureis the WASM type section entry. It is exact, not guessed.instruction_mnemonicscontains up to all opcodes in the function body; LLM backends truncate to the first 40 when building the user message.
The backends
Offline heuristic backend
The zero-dependency default. Deterministic, no API key, no network. Runs immediately afterpip install -e . with no extras. Applies three heuristics in priority order:
The string xref heuristic is the strongest cheap signal because Emscripten modules are full of format strings, error messages, and symbol names. The placeholder heuristic ensures nothing stays anonymous; 100% symbol coverage is achievable offline.
Even if an LLM backend is selected but fails at construction time (missing key, import error),
make_backend silently falls back to the offline backend.
OpenRouter / Kimi backend (the cheap default)
Naming a function from grounded facts is bulk, high-volume work, not frontier reasoning, and it is gated by the verifier and the confidence economy. So the crew defaults to the cheapest capable backend. The OpenRouter backend uses the officialopenrouter Python SDK, so an inexpensive model like Kimi K2.6 does the naming instead of a frontier model.
Selected automatically when OPENROUTER_API_KEY is set and the openrouter package is installed (pip install -e '.[agents]'). It is tried first, before OpenAI and Anthropic.
- Default model:
moonshotai/kimi-k2.6(override withWARDEN_OPENROUTER_MODEL). - Aliases:
--backend openrouter,--backend kimi, and--backend orall select this backend. - Routing: each call passes
provider={"sort": "price"}, so OpenRouter sends it to the cheapest provider serving the chosen model. - Output: the model is asked for a JSON object and parsed leniently (the first balanced
{...}object is extracted).nameis run throughslugify;confidenceis clamped to[0.0, 1.0].
run_agent_pass(kb, version_id, roles={"naming": "openrouter", "struct": "anthropic"}).
OpenAI / Codex backend
Selected automatically whenOPENAI_API_KEY is set, the openai package is installed (pip install -e '.[agents]'), and OpenRouter is not selected. Uses the OpenAI Responses API with structured JSON output so the model returns {name, summary, confidence}.
- Default model:
gpt-5.3-codex(override withWARDEN_OPENAI_MODELorWARDEN_AGENT_MODEL). - Aliases:
--backend openai,--backend codex, and--backend oaiall select this backend. - Reasoning effort: defaults to
mediumand can be changed withWARDEN_OPENAI_REASONING_EFFORT. - System prompt: the same RE prompt used by the Anthropic backend.
- User message: contains only fields from
FunctionFacts: function index, type signature, export status, call targets, referenced strings, raw name hint, and up to 40 opcode mnemonics. - Output: the response is validated and clamped.
nameis run throughslugify;confidenceis clamped to[0.0, 1.0].
Anthropic backend
Selected automatically whenANTHROPIC_API_KEY is set, the anthropic package is installed (pip install -e '.[agents]'), and the OpenAI backend is not available. Uses the Anthropic Messages API with structured JSON output via a JSON schema constraint so the model always returns {name, summary, confidence} and nothing else.
- Default model:
claude-opus-4-8(override with theWARDEN_AGENT_MODELenvironment variable). - System prompt: instructs the model to act as a RE assistant, propose a concise snake_case C-style identifier, write a one-sentence purpose, and emit a calibrated confidence in
[0, 1]. The model is told explicitly to prefer low confidence when evidence is thin and never invent behavior unsupported by the facts. - User message: contains only fields from
FunctionFacts: function index, type signature, export status, call targets, referenced strings, raw name hint, and up to 40 opcode mnemonics. No content outside these facts is sent. - Output: the response is validated and clamped.
nameis run throughslugifyto guarantee a valid identifier;confidenceis clamped to[0.0, 1.0].
Backend selection
make_backend(prefer) in backends.py resolves which backend runs:
An explicit
--backend flag always beats auto-detection.
Per-role specialist routing
By default the crew runs one auto-selected backend for everything: the same backend thatmake_backend resolves above names functions and serves every analyzer role. This is the
unchanged behavior, and you do not have to configure anything to get it.
When you want more control, the crew can route individual roles to different backends or models.
There are two roles you can route independently:
- The naming role proposes human-readable names and summaries for unnamed functions.
- The analyzer roles are the deterministic concurrency and struct passes that emit economy-gated specialist proposals and per-function notes (see Call-graph strategy and Specialized analyzers).
make_backend
does, so every role still works with no API key and no optional package.
Running the agent crew
Store your key and model once withwarden config (no environment variables to manage), then just
run warden agent:
Deep analysis: one agent per function
warden agent names functions. warden deep goes further: it runs one agent per function,
walked bottom-up over the call graph (leaves first), to recover not just a name but a full
understanding, a variable rename map, and cleaned C.
-
Tiered models. Leaf functions are the bulk, so name them with the cheapest capable model and
escalate the harder, high-fan-in parents. A good default at scale is MiMo-V2.5-Pro for leaves
(cheapest output) and Kimi K2.6 for parents:
Measure before you commit: a
warden deeprun is tracked and reversible, so run two models on a slice, compare the names and cleaned C, and keep the winner. - Dedup. Identical functions (same stable identity) are analyzed once and reused, the lever that makes 13k-function modules tractable.
-
Reversible. Every name and variable rename is logged to
rename_history, so any change can be undone. -
Live. Progress streams to
agent_events(and, with--watch, to the terminal). The UI renders the raw and cleaned C side by side and lets you jump between functions.
OPENROUTER_API_KEY and a real --backend to make
the agents real. Results land in the function_analysis table and the symbol economy.
Call-graph strategy
By default,run_agent_pass walks the call graph bottom-up instead of running a single flat sweep. Pass --strategy flat to get the original behavior.
concurrency parameter (default 8) caps how many proposals are in-flight at once within a single layer. Set it programmatically via run_agent_pass(..., concurrency=N).
How the call-graph walk works
1
Build the call graph
build_call_graph(module) in warden.analysis.callgraph constructs a CallGraph with
direct and indirect edges for every defined function. Direct call and return_call
instructions are exact. call_indirect and return_call_indirect instructions carry only a
type index at the static level, so their targets are over-approximated: every defined
function in the module’s element table whose type matches the call’s type index is included as
a potential callee. The resulting graph is a conservative static skeleton.2
Condense recursion into layers
strongly_connected_components (iterative Tarjan) groups mutually recursive functions into
SCCs. layered_schedule then condenses the SCC graph into a DAG and assigns a depth to each
component: layer 0 holds leaves, and every later layer holds functions whose defined callees
all appear in earlier layers. Mutual recursion lands in the same layer and is treated as
a single unit. All traversals are sorted, so the schedule is deterministic.3
Route to specialist proposers
Before processing any layer, the concurrency and struct analyzers run as autonomous crew
proposers (the same passes that
warden analyze runs). They do two things at once. First,
specialist_proposals turns each finding into its own conservative, economy-gated proposal that
goes through verify_proposal and kb.upsert_symbol like any other write:- A function whose distinctive evidence is an atomic read-modify-write gets the name
atomic_rmw_siteand a summary noting a synchronization primitive. - A function the struct analyzer attributes a layout to gets the name
struct_accessorand a summary noting which field offsets it touches through a base pointer.
_specialist_notes routes every finding into per-function hint lists that feed the
namer:- Atomic sites from the concurrency analyzer produce notes such as
"atomic i32.atomic.rmw.add at offset 8; likely a synchronization primitive". - Struct layouts from the struct analyzer produce notes describing which field offsets the function accesses through a base pointer.
FunctionFacts.notes so the namer backend sees them when it proposes.
Pass run_specialists=False to run_agent_pass to skip the specialist proposals (the notes
still enrich the facts).4
Enrich each function with callee names
When a function is about to be processed,
_enrich looks up the KB names of all its direct
defined callees and attaches them as FunctionFacts.callee_names. Because layers are
processed bottom-up, the callees have already been named (or skipped) before the caller is
reached. A backend that sees callee_names=["parse_header", "validate_checksum"] has far
richer context than one that sees only raw opcodes.5
Propose each layer concurrently
All functions in a layer are independent (no intra-layer edges by construction), so their
proposals can safely run in parallel.
_propose_concurrently uses asyncio.gather with a
semaphore capped at concurrency. Backends that block (every current backend) are dispatched
via asyncio.to_thread so the event loop stays responsive. A single-function layer skips the
async path entirely and calls backend.propose directly.6
Write back under the economy
Proposals from each layer go through the same
verify_proposal gate and kb.upsert_symbol
call as the flat pass. Because functions in the same layer cannot be each other’s callees,
concurrent branches in one layer never share a callee that is being written at the same time.
The KB’s provenance/confidence economy rejects any write that would overwrite a higher-confidence
or locked entry, so concurrent branches are safe.FunctionFacts fields added by the call-graph strategy
The call-graph strategy attaches two fields that the flat pass leaves empty:
Both fields are part of the
FunctionFacts dataclass and are forwarded to the backend as additional context in the user message.
Multi-round naming to a fixpoint
One bottom-up pass names callees before callers, but it cannot improve a caller that was processed before a later round gave its callees better names.run_agent_pass takes a rounds
argument (default 1) that repeats the whole bottom-up walk until the names stop changing.
rounds is reached, whichever comes first. Because the walk,
the analyzers, and the offline backend are all deterministic, the fixpoint is reproducible: the
same module and the same rounds cap always converge to the same names with no wall clock and no
randomness. Set rounds=1 (the default) to keep the single-pass behaviour.
When to use each strategy
Use--strategy call-graph (the default) for any module where naming quality matters. The bottom-up order means callers are named in light of what their callees do, which is the main quality improvement over a flat pass.
Use --strategy flat when you want a quick, fully sequential sweep, for example in CI environments where deterministic single-threaded output is easier to diff, or when debugging the backend in isolation.
The verifier gate
verify_proposal(proposal, facts) in crew.py sits between the backend’s output and the KB. It returns (accepted, reason).
Currently it performs cheap structural checks:
- The name must match
^[A-Za-z_][A-Za-z0-9_]*$(valid C identifier). - The name must be at least two characters.
- Confidence must be in
[0.0, 1.0]. - A summary that claims string evidence must be backed by non-empty
facts.referenced_strings.
wasm2c, where a lifted C reconstruction is recompiled and executed against the original WASM under a fuzzer corpus. warden verify <wasm> reports whether the current environment has the toolchain needed to activate it.
The behavioral verifier (wasm2c differential re-execution) is scaffolded but not yet active.
The
verify_proposal call site is where it plugs in when a C toolchain is available.The provenance/confidence economy
Every write to the KB carries three fields that together make re-running the entire crew safe. The full economy is explained in core concepts; here is how the agent crew interacts with it.provenanceis set to"agent"for every crew write. This places agent output at the lowest authority tier, below human, Oracle, export, and string-xref entries.confidenceis the calibrated score returned by the backend. The offline backend emits 0.45, 0.30, or 0.12 depending on which heuristic fired. LLM backends are instructed to self-calibrate and their output is clamped to[0.0, 1.0].lockedis never set by the crew. Onlywarden set-name(human writes) setslocked=True, which makes an entry immutable to every automated actor.
- Before the backend is called: entries at
confidence >= 0.5or markedlockedare skipped. The thresholdSKIP_CONFIDENCE = 0.5is the boundary between “confident enough to leave alone” and “fair game.” - After the verifier passes:
kb.upsert_symbolenforces that an agent proposal may only land if no higher-confidence agent entry (or any higher-authority entry) already exists. The result is counted asrejected_by_economyand no write happens.
warden agent on a module that already has Oracle matches and a prior agent pass at confidence 0.45 will re-propose only the functions that are still below threshold, and only overwrite those where the new proposal is stronger.
Specialized analyzers
Beyond the naming crew, WARDEN ships two deterministic analyzers that populate first-class KB facts with no LLM and no API key. They cover the concurrency and type/struct roles from the intended crew architecture and run together under a single command:Concurrency analyzer
warden.analysis.concurrency.analyze_concurrency(module, kb, version_id) recovers the thread
model from three byte-level fossils that survive Emscripten stripping:
The pass returns a
ConcurrencyReport with .shared_memory, .atomic_sites, .pthread_markers,
and .facts. When a KB and version ID are supplied, each atomic site is written to the
thread_model table via kb.add_thread_fact as kind='atomic', with the memarg offset as the
best-effort “guarded data” pointer and a confidence of 0.6. This is high enough to be a fact but below the
human/Oracle tier, because the exact guarded data is a best-effort guess.
A module is considered multithreaded when any of the three signals is present. Shared memory or
atomic opcodes are conclusive; pthread-named symbols are a weaker hint (a module may import them
without actually spawning threads), but they are still recorded.
Struct-layout analyzer
warden.analysis.structs.analyze_structs(module, kb, version_id) reconstructs candidate struct
shapes from memory-access patterns. Emscripten compiles a C struct field access into a recognizable
two-instruction sequence:
(base local, offset) pair is one candidate field; multiple accesses to the same offset are
deduped. Fields are grouped by base local into a StructLayout named
<func>_arg<N>_t, and the recovered fields are sorted by offset so the output is deterministic.
Each StructLayout has a .name, .fields (a list of StructField(offset, size, type, name)),
and .source_function. When a KB and version ID are supplied, every layout is persisted via
kb.upsert_struct at provenance agent, confidence 0.5, so the recovered shapes become
queryable KB facts and carry forward on the next ingest.
Running both passes
warden analyze <label> runs both analyzers in sequence and prints a summary:
warden agent run sees
thread_model and structs entries when assembling FunctionFacts) and to the HTML report
generator.
Intended crew architecture
The current implementation runs a single generic naming pass. The target architecture from the design is a crew of specialized agents, each owning a distinct domain:Oracle agent
Oracle agent
Adjudicates fuzzy Oracle matches and attaches upstream Emscripten/musl source links to matched symbols.
Concurrency agent
Concurrency agent
Owns atomic/lock/TLS analysis; labels lock-to-guarded-data relationships and worker entry points discovered via dynCall/elem tables. Implemented as a deterministic pass in
warden.analysis.concurrency. warden analyze runs it and persists findings to the thread_model table. It also runs inside the call-graph crew as an autonomous proposer that emits conservative, economy-gated names and notes for the namer.Type/struct agent
Type/struct agent
Reconstructs struct layouts from memory access patterns and propagates types to callers. Implemented as a deterministic pass in
warden.analysis.structs. warden analyze runs it and persists findings to the structs table. It also runs inside the call-graph crew as an autonomous proposer that emits conservative, economy-gated names and notes for the namer.Naming/summarization agent
Naming/summarization agent
Proposes human-readable names and writes pseudocode summaries. This is what the current implementation does.
Diff agent
Diff agent
On each new version, explains modified functions and writes the semantic changelog.
Verifier agent
Verifier agent
Builds differential test harnesses and triages mismatches between the WASM and its reconstruction.
warden mcp, so any MCP-capable model can drive the loop. The current warden agent command is the starting spine for this architecture.
Relation to other pipeline stages
- Oracle identification runs before the agent crew and pre-populates the KB with high-confidence names for runtime/libc functions. In a real Emscripten module, 40–80% of functions may already be named by the time the crew runs. Those are skipped, so the crew concentrates effort on the application-specific remainder.
- Diff carry-over runs on ingest of a new version and ports annotations from the previous version. After carry-over, only genuinely changed or new functions are below threshold, so the crew only touches what actually needs attention.
warden demoruns the full pipeline end-to-end offline and shows all three stages feeding each other (Oracle → agent (offline) → diff carry-over), with no API key.