This document describes how PatchDiff-AI is built end-to-end: the layers, the data flow, the state machine, the tools, the I/O, and the design constraints that shaped the rewrite. It covers the greenfield package at src/patchdiff_ai/.
If you only want to run the tool, start at readme.md. If you want to understand how it works or extend it, this is the right document.
- Design principles
- The CVE → report lifecycle
- Process model
- Layers
- State machine
- Interrupts & interactivity
- Concurrency model
- Caching strategy
- Error handling & cancellation
- On-disk layout
- Walk-through: CVE-2025-29824
- Extending the system
The greenfield rewrite was driven by a small number of constraints that the legacy implementation violated and that we wanted to fix once and for all:
- No module globals. Every component takes an
AppContext. There are no class-level mutable singletons (the legacyAgentModelsgod-object), no import-timesys.exit(1)on auth failures, and no module-levelpt = PatchTools()handles that survive across CVE runs. The DI bundle is built once incli/app.pyand threaded through. input()lives only in the CLI process. Graph nodes never callinput(). When refinement is needed, a node yieldsinterrupt(RefinementRequest); the orchestrator surfaces it to aCliInteractor, which is the only place that actually prompts the user.- Async correctness.
await asyncio.to_thread(async_fn)is gone. Tool wrappers useasyncio.create_subprocess_exec(noshell=True) with mandatory timeouts. Cross-CVE state shared between graph runs is replaced with explicit per-run state. - Pydantic v2 everywhere. State models, settings, and cross-agent contracts are
BaseModelsubclasses. Cross-node merging uses LangGraph reducers (Annotated[list[T], append_list],add_messages). - Explicit state machine. The pipeline graph uses a
Stageenum and aPipelineRouterwhose methods are pure functions of state. There is nomatchonstate_info.node[-2]. (The pipeline isn't a "supervisor" in the LangGraph multi-agent sense — there's no LLM-driven routing; every transition is a deterministic function of state.) - Subprocess discipline. A single
tools/process.pyrun()helper enforces timeouts, structuredToolError/ToolTimeoutexceptions, argv-style invocation, and Ctrl-C-clean child cleanup. Every tool wrapper depends on it. - Centralized prompts and structured observability. Prompts are markdown files
under
prompts/, loaded byPromptRegistry. Logging is structlog JSON tee'd to disk; LLM calls are instrumented via a LangChain callback handler that records tokens, latency, and cost per call.
user invokes: patchdiff-ai cve CVE-2025-29824
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 1. CVE info │
│ - Resolve OS / product ID (cached on disk) │
│ - Fetch MSRC CVRF + SUG report for the CVE │
│ - Pick the (current, previous) KB pair │
│ - If a cached report already exists in Chroma → short-circuit │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 2. Gather info │
│ - Download both KBs (.msu) from the Update Catalog │
│ - Extract: 7-Zip → nested .cab/.psf → forward / reverse delta apply │
│ (UpdateCompression.dll RAII-wrapped) │
│ - Build executable index (Polars DataFrames): │
│ prev / curr KB + winsxs r-patch baseline │
│ - For each unseen (name, package) pair, ask an LLM for an 80-token │
│ file description and embed it into Chroma `windows.exe.desc` │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 3. Platform internals │
│ - LLM derives a similarity-search query from CVE metadata │
│ - Top-10 file_info candidates retrieved by vector search │
│ - LLM rescores them on a 0-10 relevancy scale │
│ - Optional: interrupt() → CLI lets user add semantic / filename │
│ candidates │
└────────────────────────────────────────────────────────────────────────┘
│ Send fan-out (one per candidate above the relevancy threshold)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 4. Reverse engineering (per candidate) │
│ - IDA: produce .BinExport for primary + secondary binary │
│ - BinDiff: build .BinDiff database (one bounded retry on │
│ sqlite corruption with override=True) │
│ - For each function with similarity < 1.0: decompile to C in │
│ __funcs__/<address>.c (via IDA decompile.py script, batched) │
│ - Emit one Artifact per candidate │
└────────────────────────────────────────────────────────────────────────┘
│ Send fan-out (one per Artifact)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 5. Vulnerability research (per Artifact) │
│ - Index decompiled functions and compute udiffs │
│ - LLM scores each function on a 0-1 security-relevancy rubric │
│ (with token-aware truncation at ~100k tokens) │
│ - Refinement loop: if no high-confidence report yet and │
│ iter_remaining > 0, drop the analyzed slice and rank the rest │
│ - Functions above the security_modification threshold → analyzed │
│ - In --eval mode: parallel report generation across multiple LLMs │
│ - Persist each Report into Chroma `windows.exe.rca.reports` │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 6. Finalize │
│ - Log cve_run_complete with report count │
│ - Reports flow back through PipelineState │
└────────────────────────────────────────────────────────────────────────┘
A single patchdiff-ai invocation is one OS process. Inside it:
- One asyncio event loop drives everything. CPU- or blocking-I/O-bound work
(Chroma writes, sync HTTP via
requests-html,BinDiff.from_binexport_files) is pushed onto the default thread-pool viaasyncio.to_threadorloop.run_in_executor. - External tools (
7z.exe,idat64.exe) run as child processes throughasyncio.create_subprocess_exec. They are timeout-bounded; on cancellation the process isterminate()-then-kill()ed. - Vector stores are local Chroma collections persisted to
db/.
Per CVE:
- A single LangGraph
Sendfan-out spawns the RE subgraph for each ranked candidate. - Each RE run produces an
Artifactthat is fanned out again to a VR subgraph. - All fan-outs occur within the same event loop and rely on LangGraph's built-in scheduling — there is no manual thread pool.
The package is layered top-down: CLI → Runtime/Orchestrator → Graphs (subagents) → Tools / Patches / Persistence / LLM Registry / Schemas / Observability. Lower layers don't import from higher ones.
1. CLI (src/patchdiff_ai/cli/)
The CLI uses Click for the plugin-aware surface and keeps typer for
the legacy cached command (mounted via typer.main.get_command(...)).
cli/root.py defines the Click root
and mounts a thin generic core plus one self-registered sub-group per
platform provider.
patchdiff-ai [-L LOG_LEVEL]
├── cve <CVE-ID> [--platform N] [--eval] [--interrupt] [--chat] [--chat-permissive]
│ # auto-detects via parallel native (MSRC/USN/...) → NVD fallback
├── health-check # core checks + every provider's health_check()
├── install # core IDA assets + every provider's install()
├── cached # legacy typer command (mounted via typer→click bridge)
├── windows # registered by WindowsProvider
│ ├── cve <CVE-ID> [--platform-id N] [--eval] [--interrupt] [--chat] [--chat-permissive]
│ ├── health-check
│ ├── install
│ └── month <YYYY-MMM> [--platform-id N] [--platform-name X]
└── linux # registered by LinuxProvider (skeleton)
├── cve <CVE-ID> [--distro X] [--release Y]
├── health-check
└── install
Conventions:
cli/app.py'smain()is a thin adapter: it runs_bootstrap()(env-strip + tracing-disable BEFORE LangGraph imports) and then dispatches to the Click root incli/root.py.- The root group's
-L/--log-levelcallback runs first and configures structlog before any subcommand body. _bootstrap()force-disables LangSmith / LangChain tracing (LANGCHAIN_TRACING_V2=false,LANGSMITH_TRACING=false) plus strips any inherited API key / endpoint. LangChain capturesLANGCHAIN_*at import time, so this has to happen first.- Shared CVE-running flags (
--eval / --interrupt / --chat / --chat-permissive) are factored into a@cve_optionsdecorator incli/options.py; everycve-style command uses it so help stays consistent. - All three
cveentry points (rootcve,windows cve,linux cve) delegate to a single dispatcher incli/runner.py(run_single_cve). The CLI never builds anAppContextdirectly. cli/validators.pyis now down to justcve_value. TheYYYY-MMMregex (MONTH_RE) and CSV parsing for--platform-idmoved into the windows plugin.KeyboardInterrupt/EOFErrorexit with code 130 (the standard "Ctrl-C" code).
2. Configuration (src/patchdiff_ai/config/)
Built on pydantic-settings. The root model is Settings in
config/settings.py. It composes:
AzureCreds,AnthropicCreds,GeminiCreds— provider credentials.Paths— filesystem layout. A singledata_root(defaulting to%APPDATA%/patchdiff-aion Windows or~/.local/share/patchdiff-aielsewhere — seeruntime/app_dirs.py) anchorsdb_dir,reports_dir,temp_dir,logs_dir,patch_store_dir, andwindows_sxs_dir. Amodel_validator(mode="before")fills any path left asnullin config.json with itsdata_root-relative default, so users only override the paths they care about. Overridedata_rootitself via thePATCHDIFF_AI_HOMEenv var.ToolPaths— executable paths and a globalprocess_timeout_seconds(default 30 minutes; IDA/BinDiff jobs can be long).Thresholds— three knobs:candidates: 7.5(0-10) — minimum LLM relevancy to send a candidate to the RE pipeline.security_modification: 0.25(0-1) — minimum function-level security score for the VR pass.report: 0.1(0-1) — confidence threshold above which we accept a generated report and stop iterating.
Concurrency— semaphores and worker counts (file_info_semaphore=500,re_workers=5,extractor_workers=5,llm_eval_parallel=4). Tune viaCONCURRENCY__<field>=N.ModelChoices— per-purpose model name overrides. Each field uses a single- underscore alias (MODELS_DEFAULT,MODELS_GATHER_INFO, …) — not the nestedMODELS__<field>form. This is a deliberate exception to the general nesting rule so model overrides remain flat in env vars.
Settings are read from <data_root>/config.json + process env.
Settings.settings_customise_sources wires a _BlankStrippedJsonSource
(strips null placeholders so they fall through to the next source)
between init_settings / env_settings and the model defaults. The
auto-create hook in AppContext.build() writes a starter config.json
on first run; users can also bootstrap explicitly via patchdiff-ai init.
Precedence (highest first): explicit kwargs → env vars → config.json →
defaults. Nested env fields use the __ delimiter
(PATHS__DB_DIR=/data/db, TOOLS__SEVEN_ZIP=..., CONCURRENCY__RE_WORKERS=...).
Legacy flat names (AZURE_ENDPOINT, ANTHROPIC_API_KEY) and the per-purpose
model overrides are honored via Field(alias=...).
get_settings() is lru_cached so config.json is read once per process.
3. AppContext (dependency injection) (src/patchdiff_ai/runtime/app_context.py)
The single DI bundle. Every node, tool, and CLI command takes one. It holds:
@dataclass
class AppContext:
settings: Settings
registry: ModelRegistry
tools: Tools # SevenZipTool, IdaTool, BindiffTool, DeltaApi, ...
log: structlog.BoundLogger
vector_stores: VectorStores | None # opened lazily (needs the embedding model)
prompts: PromptRegistry | None
progress: ProgressReporter # Rich-driven; NullProgressReporter by defaultAppContext.build(settings) constructs everything except vector_stores (those are
opened on demand because they require resolving an embedding model from the
registry). AppContext.close() releases the DeltaApi Win32 handle.
4. LLM registry & providers (src/patchdiff_ai/llm/)
The legacy AgentModels god-object (six untyped class attributes mutated at import
time, with sys.exit(1) on auth failure) is replaced by an explicit, lazy registry.
llm/catalog.py— a frozenMODEL_CATALOGdict ofModelSpecentries (name, provider, deployment, api_version, cost_per_mtok, max_tokens, temperature, is_embedding). Six purposes are defined inModelPurpose:EMBEDDING,DEFAULT,GATHER_INFO,PLATFORM_INTERNALS,REVERSE_ENGINEERING,RESEARCHER. Each has a default model inDEFAULT_BY_PURPOSE.EVAL_MODELSis the tuple used in--evalmode.llm/registry.py—ModelRegistryresolves aModelPurposeto aModelEntry(spec, model)lazily. Lookup order: env override → purpose default → any available chat model. Failures becomeProviderUnavailableError, never silent fallbacks.list_available()reports which catalog entries have configured credentials.llm/auth.py—build_azure_credentialresolves to either aClientSecretCredential(if the SP trio is set) orDefaultAzureCredential.cognitive_token_providerbuilds a callable token provider for the Cognitive Services scope. Nosys.exiton failure. The registry warns and marks Azure as unavailable.llm/providers/{azure,anthropic,gemini}.py— thin per-provider factories that turn aModelSpec+ creds into a LangChain chat or embedding client.
5. Tools (src/patchdiff_ai/tools/)
Every tool wrapper is async, list-arg, timeout-bound, and built on
tools/process.py's run() helper.
| Module | Purpose |
|---|---|
process.py |
run(args, *, timeout, ...) — create_subprocess_exec, kill on timeout, ToolError / ToolTimeout |
seven_zip.py |
SevenZipTool.list_files(...) / extract_by_list(...) — reads supported extensions from 7z i |
psf.py |
PsfArchive — async ctx mgr; mmap closed on exit (legacy leak fixed) |
ida.py |
IdaTool.run_script(IdaJob) — -S<script> arg via subprocess.list2cmdline; batch() dedupes targets |
ida_mcp.py |
IdaMcpService — chat-only ida-pro-mcp wrapper; get_catalogue() (no spawn) + call_tool(name, args) (lazy spawn). Used by the chat tool catalogue. |
idalib_pool.py |
IdalibPool — N-worker multiprocessing.spawn pool driving idalib directly. Used by the RE pipeline (no MCP overhead). None when idalib isn't activated. |
_subprocess_lifecycle.py |
Shared atexit-killed PID registry + port helpers + terminate dance; used by both idalib_pool and ida_mcp. |
bindiff.py |
BindiffTool.diff(...) — one bounded retry on sqlite corruption (override=True), no infinite loop |
delta.py |
DeltaApi — instance-scoped ctypes loader for UpdateCompression.dll; DLLs stay mapped until process exit (FreeLibrary mid-shutdown crashed Py_Finalize) |
manifest.py |
WcpManifestExtractor — guarded string_at for WCP manifest decoding |
idapython/ |
analyze.py and decompile.py — legacy IDA-side scripts invoked via idat64.exe -A -S<script> (8.x fallback) |
Conventions:
- Executable paths come from
Settings.tools— no hardcoded'C:/Program Files/...'. Whentools.idaisn't set,config/tools.pydiscovers all installs underProgram Files(recognisingIDA Pro 8.x,IDA Professional 9.x) and picks the newest.idat.exe(9.3+) andidat64.exe(8.x / 9.0) are both supported. SevenZipToolis never invoked withshell=True.IdaTool.batchdeduplicates jobs by target path because IDA cannot share an.i64file across concurrent processes.IdaJob.argsis escaped viasubprocess.list2cmdlineexactly once before being passed in-S.BindiffTool.diffretries once onsqlite3.DatabaseError. Two failures in a row returnNone; the RE pipeline treats that as "skip this candidate". The bundledbindiff.exeships underresources/bindiff_ida_9.3/and is wired in viaBINDIFF_PATH(set inAppContext.build()), so users don't need a separate BinDiff install.- The chat agent uses a hybrid tool catalogue (
cli/chat_agent.py): 3 always-on native tools (list_changed_functions,show_decompiled,show_diff) + 3 meta-tools (list_tools,describe_tool,call_tool) proxy everything else (report queries, reanalyze, all 60+ ida-pro-mcp tools). Theidalib-mcpsubprocess only spawns on the firstcall_tool(<ida tool>, ...)— chat sessions that never touch live IDA pay zero spawn cost. Catalogue snapshot is built without spawning by importingida_pro_mcp.ida_mcpand walking the in-process@toolregistry. - The RE pipeline drives idalib through
IdalibPooldirectly (no MCP overhead) when the resolved IDA is 9.0+ with idalib activated; 8.x setups keep using the legacy subprocess flow.
6. Patches pipeline (src/patchdiff_ai/patches/)
This is the I/O-heavy layer that owns everything between "user gave us a CVE ID" and "we have an executable index ready to embed".
| Module | Responsibility |
|---|---|
cve_enrichment.py |
Fetch the MSRC SUG report for a CVE, return CveMetadata |
platform_filter.py |
Download a CVRF for a Patch Tuesday, pick product IDs, collect CVEs |
os_detection.py |
(name, productId) from CVRF; processor_arch_tokens() from the host. No input() — pure functions |
kb_downloader.py |
download_kb(...) — requests-html + tenacity, streamed to disk under resource_lock, cancellable |
extractor.py |
extract_kb(...) — _KBExtractor worker pool (default 5) handling 7-Zip + PSF + nested archives |
files_collection.py |
Polars DataFrame builders for winsxs / update reports; file_desc() (PE-version-info reader) |
manifest_extractor.py |
Pulls package / publisher tokens from WCP manifests |
delta_apply.py |
patch_entry(...) — apply forward / reverse deltas through the injected DeltaApi |
Disk-full is a first-class signal: extract_kb raises DiskFullError (from
runtime/errors.py) instead of letting the
worker pool deadlock. The CLI catches it, logs once, and exits non-zero with no
traceback.
7. Persistence (src/patchdiff_ai/persistence/)
| Module | What |
|---|---|
vector_store.py |
VectorStores — three Chroma collections: windows.exe.desc, windows.exe.functions.logic, windows.exe.rca.reports. Telemetry disabled before import. |
patch_store.py |
safe_serialize (atomic write-temp → fsync → rename), get_patch_store_df, resource_lock (asyncio version of the legacy weakref-Lock table) |
caches.py |
DiskCache — small JSON-on-disk cache used for os selection and any one-shot lookups |
The windows.exe.rca.reports collection is the persistent layer for the report
short-circuit: a CVE's first run writes its reports here; subsequent runs (without
--eval) detect the cached entries in the cve_info node and skip everything.
8. Schemas (src/patchdiff_ai/schemas/)
Cross-agent contracts. All Pydantic v2.
| Module | Models |
|---|---|
core.py |
Type aliases: CveId, KbId |
cve.py |
CveMetadata (MSRC payload), CveDetails (CVE id + description + msrc_report) |
patch_store.py |
PatchStoreEntry, PatchSources (base / current / previous), OsDetails |
analysis.py |
Artifact (primary/secondary file + diff + changed funcs), DecompiledFunction, FunctionMatchRef |
candidate.py |
Candidate, RankedCandidate (adds 0-10 LLM relevancy), Candidates (query + results) |
report.py |
FunctionRelevancy, VulnFuncs, VulnReport (LLM structured-output schema), Report (the artifact persisted to Chroma) |
messages.py |
MessagesState mixin |
reducers.py |
append_list, replace, re-export of add_messages |
All Artifact-bearing states use arbitrary_types_allowed=True because we keep raw
bindiff.BinDiff and bindiff.FunctionMatch objects on the state — they aren't
Pydantic models but they don't need to be serialized across the wire.
9. Graphs (src/patchdiff_ai/graphs/)
Each subgraph follows the same shape:
graphs/<name>/
├── state.py # Pydantic v2 BaseModel with reducer-annotated fields
├── nodes.py # `make_nodes(ctx)` factory returning callables
└── graph.py # `build_<name>_graph(ctx)` returning a compiled StateGraph
graphs/builder.py holds shared
constructor helpers; graphs/interrupts.py
defines the cross-process payloads (see Interrupts & interactivity).
Pipeline graph (graphs/pipeline/)
The top-level state machine. Despite the LangGraph "supervisor" terminology the legacy code used, this is a deterministic pipeline with conditional fan-out — every transition is a pure function of state, no LLM-driven routing.
- State (
state.py):PipelineStatewithstage: Stage, CVE / OS / KB slices, three Polars DataFrame slices (raw, filtered, base),candidates,artifacts(reducer:append_list),reports(reducer:append_list),messages(reducer:add_messages), andparse_errors. - Routing (
routing.py): aStageenum + aPipelineRouterclass with one method per source stage:from_cve_info— short-circuit to FINALIZE if reports were cached, else GATHER.from_gather— straight to PI_AGENT.from_internals— fan-out viaSendto RE for each candidate above thecandidatesthreshold whose patch_store entry has at least two KBs available.from_re— fan-out viaSendto VR for eachArtifact.from_vr— straight to FINALIZE.from_finalize— straight to END.
- Nodes (
nodes.py):cve_info_node— readsctx.platform(resolved at the CLI layer beforerun_cvewas invoked), delegates the advisory fetch + KB selection toctx.platform.enrich_cve(state, ctx), then checks the Chroma reports cache and optionally short-circuits to FINALIZE.gather_node— thin wrapper aroundctx.platform.gather_packages(state, ctx). The Windows plugin invokes the existing gather subgraph here; future platforms own their own implementation. Callsctx.progress.stop_live()on exit so the PI / RE / VR phases run on a clean tty (no live progress bars to clobber refinement input).finalize_node— logscve_run_completeand sets the stage.
- Graph topology (
graph.py):CVE_INFO ──► [GATHER | FINALIZE] GATHER ──► PI_AGENT PI_AGENT ──► [Send(RE_AGENT) ... | FINALIZE] RE_AGENT ──► [Send(VR_AGENT) ... | FINALIZE] VR_AGENT ──► FINALIZE ──► END
Gather subgraph (platforms/windows/gather_info/)
This subgraph is plugin-internal to the Windows provider — the shared
graphs/ tree no longer contains any Windows-specific code. Other
providers may implement gather_packages however they like (as a
subgraph, a coroutine, anything that returns
{extracted, dataframes, filtered_dataframes}); they don't import from
here.
- Nodes:
download— concurrentdownload_kbfor current + previous, then concurrentextract_kb, thenload_delta_dlls(each KB carries its ownUpdateCompression.dll).index— Polars-driven join of curr/prev/winsxs DataFrames, filtered by architecture and r-patch availability.add_file_info_if_needed— conditional edge: for each unseen(name, package),Sendanadd_file_infotask.add_file_info— bounded byConcurrency.file_info_semaphore. Embeds an 80-token LLM file description intowindows.exe.desc.update_vector_store— placeholder; thefile_infocollection is updated incrementally above.
- Topology:
DOWNLOAD → INDEX → (ADD_FILE_INFO* | UPDATE_VS) → UPDATE_VS → END.
LLM add_file_info calls are wrapped with tenacity (5 attempts, exponential
backoff up to 60 s) on RateLimitError. The "Windows executable" file-description
prompt lives at prompts/windows/file_description.system.md
(PromptId.WINDOWS_FILE_DESC).
Platform-internals subgraph (graphs/platform_internals/)
Platform-driven candidate ranking: collect and rank look up their
prompts via ctx.platform.candidate_prompts() and project the advisory
metadata via ctx.platform.candidate_metadata(state.cve_details). Only the
data sources are platform-dependent; the ranking algorithm itself is shared.
- Nodes:
collect— LLM derives a search query from the platform-projected advisory metadata; top-10 candidates pulled fromwindows.exe.descviaasimilarity_search_with_score.rank— LLM rescores each candidate on a 0-10 relevancy rubric; sorted by relevancy. Renders the candidates table to terminal once per run (no longer DEBUG-gated).user_refinement— interrupt-based, two-phase per loop iteration:interrupt(RefinementRequest)— CLI prompts kind (semantic/filename) and the query/pattern.- The node runs the search inside the graph (filename = case-insensitive
substring match on the
filtered_dataframeschanged-files DataFrame; semantic = vector search atk=50then filtered to changed-files set). interrupt(RefinementPickRequest)— CLI renders a numbered list and promptsSelect (e.g., 1,3,5 or 'all'). Only picked entries are added touser_docs. Loops until the user enters an empty kind (skip).
- Topology:
COLLECT → RANK → (USER_REFINEMENT | END). Whether refinement runs depends on theinterrupt: boolfrom the run config. - Prompts live in
prompts/platform_internals/{collect,rank}.system.md(the default Windows set; the active platform plugin can override the prompt IDs).
Reverse-engineering router + backends (graphs/reverse_engineering/)
RE_AGENT is a router (router.py)
that picks a backend per Send by asking
ctx.platform.classify_candidate(candidate) -> RECategory. Two
backends today, both produce the same Artifact / FunctionMatchRef
shape so VR is agnostic:
-
Binary backend (
binary_graph.py) — IDA + BinDiff + Hex-Rays decompile. Selected forRECategory.BINARY(Windows always; Linux when the candidate ends in.so/.dylib/.exe/...). Picks between twomake_nodesimplementations at build time based onctx.tools.idalib:- idalib-backed (preferred,
nodes_idalib.py) — drives idalib directly throughIdalibPool. No subprocess per pair, warm IDB cache reuse, batched Hex-Rays decompile in one round-trip. Selected whenctx.tools.idalib is not None(IDA 9.0+ with idalib activated). - idat-subprocess legacy (
nodes.py) — fallback for 8.x setups. Spawnsidat.exe -A -S<analyze.py>then-S<decompile.py>(batched 500 funcs per.i64). The IDA-side script explicitlyidc.save_database("", 0)s beforeqexitso the.i64survives the run.
Topology:
ANALYZE → DIFF_AND_DECOMPILE → END. Shared helpers (discover_parents,hexish,decompile_set_via_idalib) live in_shared.py. - idalib-backed (preferred,
-
Source backend (
source_graph.py) — text udiff for source-code candidates. No IDA, no BinDiff. Selected forRECategory.SOURCE(Linux source-package diffs, kernel patches, scripts). Single nodeDIFF_SOURCES: reads pre/post text, writes__funcs__/<identifier>.txtfor both sides, emits oneFunctionMatchRef(identifier=<filename-stem>, extension="txt")per changed file. Per-function splitting is left as follow-up — the schema and VR support multipleFunctionMatchRefs perArtifactalready.
The FunctionMatchRef schema in
schemas/analysis.py carries
both the binary-only fields (address1/2: int, similarity / confidence: float) and the M3 generalised fields (identifier: str, extension: str). VR's disk lookup is <__funcs__>/<key>.<ext> where
primary_key() / secondary_key() fall back to the hex address when
identifier is empty — so the binary path stays byte-identical to
pre-M3.
Why the merged diff+decompile node in the binary backend? Splitting them caused
cannot pickle 'sqlite3.Connection' objectwhen the checkpointer tried to serialize a liveBinDiffbetween nodes — true for both idalib and subprocess implementations.
Adding a new backend (script-runtime, manifest, ...) is additive: drop a new
<kind>_graph.py, add anRECategory.<KIND>enum value inplatforms/base.py, register it inrouter.py's_BACKENDS, and have at least onePlatform'sclassify_candidatereturn the new value.
Vulnerability-research subgraph (graphs/vulnerability_research/)
- State carries the per-Artifact slice:
artifact,cve_details,decompiled(list ofDecompiledFunction),reports(withappend_listreducer),iter_remaining: int = 3. - Nodes:
indexing— read each changed function's.cfiles (before / after), compute a unified diff, buildDecompiledFunctionentries (with parent call-stack from the BinDiff secondary side).rank— score functions on a 0-1 security-relevancy rubric usingwith_structured_output(VulnFuncs, include_raw=True). Applies a token-aware truncation: drops trailing functions until the prompt fits under 100 k tokens.parsing_errors are logged, not swallowed.analyze— picks functions abovethresholds.security_modification, then:- In normal mode: one call to
MODELS_RESEARCHERwith structuredVulnReport. - In
--evalmode: parallel calls acrossEVAL_MODELSusingasyncio.gather(..., return_exceptions=True). - Per-(cve, file, model) caching short-circuits already-generated reports.
- Decrements
iter_remainingand removes the analyzed slice fromdecompiled.
- In normal mode: one call to
refinement(conditional edge): if a high-confidence report exists →GENERATE; else ifiter_remaining > 0and there are still functions → re-runRANKon the remainder; else →GENERATE.generate— persist accepted reports towindows.exe.rca.reportswith metadata (cve, kb, file, patch_store_uid, confidence, change_count, date, model_name, cvss).
- Topology:
INDEXING → RANK → ANALYZE → (RANK | GENERATE) → END. - Prompts live in
prompts/vulnerability_research/{score_functions,generate_report}.system.md.
10. Runtime / orchestrator (src/patchdiff_ai/runtime/)
| Module | Responsibility |
|---|---|
app_context.py |
The DI bundle — built once, threaded everywhere. |
orchestrator.py |
run_cve(ctx, cve, ...) — invokes the pipeline graph, resumes on __interrupt__, returns final state |
interactive.py |
CliInteractor.handle(RefinementRequest) -> RefinementResponse — the only place input() is allowed |
timer.py |
async with Timer("label"): — async-safe span timer logged via structlog |
cancel.py |
run_cancellable(coro) — Ctrl-C-clean entry point used by the CLI commands |
errors.py |
DiskFullError + free_bytes_for(path) — domain errors that the CLI exits on cleanly |
The orchestrator is deliberately small. The legacy implementation wrapped an async def run in asyncio.to_thread() and never awaited the generator — that bug is
fixed simply by await graph.ainvoke(...).
The interrupt loop:
feed = initial
while True:
state = await graph.ainvoke(feed, config=config)
if "__interrupt__" not in state:
break
req = state["__interrupt__"][0].value
if isinstance(req, RefinementRequest):
response = interactor.handle(req)
feed = Command(resume=response)
else:
breakconfig["configurable"] carries the per-run knobs that nodes consult (cve, evaluate
flag, interrupt flag, platform tuple, threshold dict). config["callbacks"]
carries the LLM metrics handler.
11. Observability (src/patchdiff_ai/observability/)
| Module | Responsibility |
|---|---|
logging.py |
configure_logging(level, logs_dir) — dual-stream structlog: Console renderer to terminal, JSONRenderer to the per-run file in logs_dir. Also defines the TRACE level (below DEBUG). |
trace.py |
bind_cve(cve, run_id) — contextvar binding so every event carries the CVE / run id automatically |
metrics.py |
LLMMetricsHandler — LangChain callback emitting llm_call events with model, tokens, latency, cost |
progress.py |
Rich-driven ProgressReporter (download / extract bars). NullProgressReporter is the default |
Logging output is JSON by default and tee'd to logs/<unix>.<uuid>.log so every
run leaves a durable trace on disk.
No external telemetry. cli/app.py _bootstrap() pins
LANGCHAIN_TRACING_V2=false and LANGSMITH_TRACING=false and strips any
inherited LANGCHAIN_* / LANGSMITH_* API key / endpoint before it imports
the LangGraph modules (LangChain captures those at import time). Chroma's
anonymous telemetry is disabled before chromadb imports
(vector_store.py). All
observability stays local — structlog → logs/<unix>.<uuid>.log.
12. Prompts (src/patchdiff_ai/prompts/)
System prompts are markdown files. PromptRegistry.default() loads them at
construction. Nodes look them up via a PromptId enum:
prompts/
├── registry.py
├── platform_internals/
│ ├── collect.system.md # used by pi.collect (search-query derivation)
│ └── rank.system.md # used by pi.rank (file relevancy scoring)
├── windows/
│ └── file_description.system.md # Windows provider: `add_file_info` embed prompt
└── vulnerability_research/
├── score_functions.system.md # used by vr.rank (function relevancy scoring)
└── generate_report.system.md # used by vr.analyze (RCA report generation)
Provider-specific prompts (today: only windows/) live under
prompts/<provider>/. New providers add their own subdirectory and a
matching PromptId enum entry.
Inline f-strings for CVE metadata are replaced with a JSON-formatted
HumanMessage-builder helper inside the VR nodes.
13. Platforms (src/patchdiff_ai/platforms/)
The pipeline is platform-shaped, not Windows-shaped. Two protocols at
platforms/base.py:
PlatformProvider— group-level (one perwindows,linux, ...). Owns the Click sub-group, runs auto-detect, aggregateshealth_check/install, resolves CLI overrides to a concretePlatform.Platform— per-version (one per Windows release, one per distro/release pair, ...). Owns the pipeline-facing methods:enrich_cve,gather_packages,candidate_prompts,candidate_metadata.
| Module | Responsibility |
|---|---|
base.py |
Platform + PlatformProvider Protocols + UnknownPlatform / UnsupportedPlatform exceptions. |
__init__.py |
providers() registry + resolve_for_cve(cve_id, platform_override=None) (parallel native → NVD fallback). |
nvd.py |
NVD CPE lookup helper, on-disk cached at <db_dir>/.nvd/<cve>.json (30-day TTL). Used as the fallback in resolve_for_cve. |
windows/ |
WindowsProvider (real impl). Owns N WindowsVersionedPlatform instances loaded from platforms.json. matches_native hits MSRC; matches_nvd matches Windows CPE prefixes. |
linux/ |
LinuxProvider (skeleton). Pre-declares ubuntu/debian distros so the CLI tree shows up; pipeline-facing methods raise NotImplementedError until wired. |
add_platform.md |
Step-by-step instructions for adding a new provider. Read this when you start. |
Protocol surfaces:
class Platform(Protocol):
name: str
async def enrich_cve(self, state, ctx) -> dict[str, Any]: ...
async def gather_packages(self, state, ctx) -> dict[str, Any]: ...
def candidate_prompts(self) -> tuple[PromptId, PromptId]: ...
def candidate_metadata(self, cve) -> dict[str, Any]: ...
class PlatformProvider(Protocol):
name: str
def cli_group(self) -> click.Group: ...
def health_check(self) -> bool: ...
def install(self) -> None: ...
async def matches_native(self, cve_id: str) -> Platform | None: ...
def matches_nvd(self, cpes: list[str]) -> Platform | None: ...
def resolve(self, **overrides: Any) -> Platform: ...CVE → platform resolution (platforms/__init__.py:resolve_for_cve):
--platform <name>override → look up that provider, ask it to pick the right version (native first, then NVD, then provider's default).- No override → parallel native round. Every provider's
matches_native(cve_id)runs concurrently viaasyncio.gather. First non-Nonewins; ties pick by registration order (warning logged). - All native missed → NVD fallback. Hit NVD's CPE list once, ask
each provider's
matches_nvd(cpes)in registration order. - Both rounds missed →
UnsupportedPlatformwith a hint pointing at--platform <name>.
The CLI does the resolution before invoking the orchestrator. The
chosen Platform is stashed on ctx.platform at run_cve entry, and
every downstream node reads from there. There is no select_platform
inside the runtime any more, and no platform_ids_hint side channel.
The pipeline's Stage enum is the single source of truth for "where are we in
the pipeline?". Routing decisions never inspect message history or node names — only
state. The actual graph topology is a six-node sequence; every "branch" is a
short-circuit straight to FINALIZE, never a sideways jump:
START
│
▼
CVE_INFO ─────► reports already cached?
│ │
│ no │ yes
▼ │
GATHER │
│ │
▼ │
PI_AGENT ─────► no candidates above threshold?
│ │
│ Send(RE_AGENT) │ yes
│ per candidate │
▼ │
RE_AGENT ─────► no artifacts produced?
│ │
│ Send(VR_AGENT) │ yes
│ per artifact │
▼ │
VR_AGENT │
│ │
└──────────┬──────────────┘
▼
FINALIZE
│
▼
END
The Stage enum (START, CVE_INFO_DONE, GATHER_DONE, INTERNALS_DONE,
RE_DONE, VR_DONE, COMPLETE) is updated by the nodes as state metadata,
but routing reads the substantive state slices (reports, candidates,
artifacts) — not the enum.
The router methods at
graphs/pipeline/routing.py
mirror the diagram. They are pure functions of (PipelineState, AppContext) —
they read DataFrames and the patch store but do not mutate state. Mutations happen
inside nodes.
The hard rule: graph nodes never call input(). Otherwise the graph cannot run
under web servers, subprocess hosts, or batch schedulers.
Implementation:
- A node yields
interrupt(RefinementRequest(cve=...)). LangGraph captures this and surfaces it on the nextainvokeresult understate["__interrupt__"]. - The orchestrator catches it, dispatches by type:
RefinementRequest→CliInteractor.handle(...)— prompts kind + query/pattern.RefinementPickRequest→CliInteractor.handle_pick(...)— renders the search results from one refinement round and promptsSelect (e.g., 1,3,5 or 'all'). These are the only code paths that callinput().
- The interactor returns the matching
Refinement{Response,PickResponse}; the orchestrator resumes viaCommand(resume=response). - The same node receives the response, runs the search inside the graph,
yields the next
interrupt(...)for picks, then loops back for the next round — or returns normally if the user skipped.
RefinementRequest / RefinementResponse / RefinementOption /
RefinementPickRequest / RefinementPickResponse / RefinementPickCandidate /
AssistantCommand are defined in
graphs/interrupts.py.
When --interrupt is on, the pipeline graph compiles with a
MemorySaver(serde=_PickleSerde()) checkpointer (required by
Command(resume=...)). The custom serde is needed because pipeline state
carries live Polars DataFrames + pathlib.Path fields that the default
JSON/msgpack serdes choke on. Non-interactive runs skip the checkpointer
entirely (no interrupt() will fire, no need to pay the per-step
serialization cost).
The post-run "assistant chat" — help, reports, save reports,
delete all reports, reanalyze, change assistant, exit — used to live
inside the graph (and broke when subgraphs were missing nodes). It now lives
entirely outside, in cli/chat.py.
Hardcoded commands (AssistantCommand enum) dispatch directly; anything else
routes through a ReAct agent (cli/chat_agent.py)
with six tools (list_changed_functions, show_decompiled, show_diff,
show_report, search_reports, reanalyze).
Tool execution is gated by default: build_chat_agent compiles with
interrupt_before=["tools"] so every tool call pauses on a [y/N] user
approval. --chat-permissive (which implies --chat) drops that flag —
the agent then runs tools straight through without asking. The two modes
share the same tool set and the same REPL; only the gating differs.
reanalyze re-enters make_reporter() and forwards force=True so the
cache short-circuit in cve_info_node doesn't make the rerun a no-op.
The asyncio loop is the single concurrency primitive. Bounds:
| Bound | Default | Where |
|---|---|---|
| Concurrent file_info LLM calls | 500 | Concurrency.file_info_semaphore → asyncio.Semaphore in platforms/windows/gather_info/nodes.py:add_file_info |
| RE workers (per-CVE, fan-out limit) | 5 | Concurrency.re_workers (currently advisory) |
| Extractor workers per KB | 5 | Concurrency.extractor_workers → _KBExtractor pool |
| LLM eval parallelism | 4 | Concurrency.llm_eval_parallel |
LangGraph handles the Send-based fan-out (RE per candidate, VR per artifact)
internally. We don't manage that thread pool.
Locking:
resource_lock(key)(inpersistence/patch_store.py) is the asyncio replacement for the legacythreading.RLockweak-ref table. Used to serialize KB downloads and patch-store writes by stable key (e.g.dest.resolve())._file_info_mutex = threading.RLock()inplatforms/windows/gather_info/nodes.pyis intentionally reentrant; it serializes Chroma writes within a single graph execution. TheConcurrency.file_info_semaphoreis the actual concurrency cap — the lock exists only because Chroma's writer is not async.
There are several caches, each at a different layer:
| Cache | Backed by | What it short-circuits |
|---|---|---|
Chroma windows.exe.rca.reports |
Chroma | Re-running a CVE in non-eval mode (cve_info_node short-circuits to FINALIZE if any report exists) |
Chroma windows.exe.desc |
Chroma | Re-embedding the same (name, package) file description |
db/.patch_store_df |
Polars binary serialization (atomic via safe_serialize) |
Re-extracting / re-deltaing the same (name, package, arch, kb) tuple |
db/.os (DiskCache) |
JSON | Re-prompting for OS / product ID |
Per-KB report.txt next to extracted dir |
Plain text | Re-extracting an MSU |
report.cache next to each KB extraction |
Pickled DataFrame | Re-building the per-KB update DataFrame |
__funcs__/<addr>.c next to each .i64 |
C-source decompilation | Re-decompiling individual functions inside an existing IDB |
Per-(cve, file, model_name) entries in reports |
Chroma | Re-asking a specific model for the same artifact in --eval mode |
Cache invalidation is manual — --eval re-runs the analysis even if reports
exist, but it still respects per-model cache hits. Deleting db/ is the way to
force a full re-run.
- Subprocess timeouts:
tools/process.pyenforces a hard timeout. On expiry itproc.kill()s and raisesToolTimeout(args, timeout, stderr_tail). - Subprocess failure: non-zero exit raises
ToolError(args, returncode, stderr_tail)ifcheck=True. - Cancellation:
tools/process.py'srun()andkb_downloader.py's_grab()handleasyncio.CancelledErrorcleanly: child processes / streaming threads are signaled to stop, partial files are deleted. - Disk full:
DiskFullErroris recognized in two places — KB streaming (errnoENOSPC) and 7-Zip stderr scraping (regex on common phrases). The extractor's worker pool setsfatal_erroron the first hit, drains the queue viatask_done(), and re-raises afterjoin(). - LLM rate limits:
tenacitydecorators on the most-frequent LLM calls (5 attempts, exponential backoff up to 60 s onRateLimitError). Other LLM failures bubble up. - Structured-output parse errors: never swallowed. Every
with_structured_output(..., include_raw=True)site checksresult["parsing_error"], logs*_parse_error, and either retries or returns nothing. The pipeline'sparse_errorslist collects these for postmortem. - BinDiff DB corruption: one bounded retry with
override=True, thenNoneis returned. The RE node treatsNoneas a soft-fail and drops the candidate. - Domain errors (
DiskFullErroretc.) propagate out ofrun_cveand are caught incli/commands/cve.py/month.py, which print a single concise message and exit non-zero (no traceback), unless--log-level trace/debugwas specified.
%APPDATA%/patchdiff-ai/ # data_root (override with PATCHDIFF_AI_HOME)
├── config.json # all settings; env vars override
├── db/ # Chroma + patch-store
│ ├── chroma.sqlite3 # Chroma index
│ ├── <collection-uuid>/... # Chroma per-collection storage
│ ├── .patch_store_df # Polars-serialized PatchStoreEntry index
│ ├── winsxs.bin # cached winsxs DataFrame
│ ├── .os # JSON cache (OS detection)
│ └── patch_store/ # extracted / patched binaries by uid
├── _temp/ # downloaded .msu + extracted_<archive>/...
│ ├── windows*.msu # downloaded KBs
│ └── extracted_windows*.msu/ # 7-Zip + PSF + delta output
│ ├── report.txt # list of executables
│ ├── report.cache # Polars-cached update DataFrame
│ └── ... # nested cabs, manifests, executables
├── reports/ # plain-text reports
│ └── <CVE>_<file>.txt
├── logs/ # per-run JSON logs
│ └── <unix>.<uuid>.log
└── windows_sxs/ # per-platform WinSxS archives + manifest
├── platforms.json
├── <product_id>.<slug>.bin
└── <product_id>.<slug>.7z
Paths are configurable in config.json (paths.db_dir, ...) or via
PATHS__<FIELD> env vars (env always wins). Each path defaults to a
data_root-relative location when unset, so users only override what
they need (e.g. point db_dir at a fast SSD).
Each binary in the patch store gets its own decompilation directory:
<patch_store>/<uid>/
├── <file> # PE binary
├── <file>.BinExport # produced by IDA + BinExport
├── <file>.<other-kb>.BinDiff # produced by BinDiff
├── <file>.i64 # IDA database
└── __funcs__/
└── <addr>.c # decompiled C, one per changed function
This is the canonical golden-reference run that every milestone validates against.
$ patchdiff-ai cve CVE-2025-29824
- CLI (typer) parses
cve_id, buildsSettingsand anAppContext._bootstrap()force-disables LangSmith / LangChain tracing before LangGraph is imported.configure_loggingopenslogs/<unix>.<uuid>.log. - Orchestrator binds the trace context (
bind_cve("CVE-2025-29824", run_id)), builds the pipeline graph, and starts the interrupt loop. cve_info_nodedetects the host OS (e.g.Windows 11 Version 24H2 for x64-based Systems, productId12390), fetches MSRC's SUG report, picks the product-specific entry, and resolves the(current=KB5055523, previous=KB5053598)pair (illustrative).gather_info:- Downloads both
.msus into_temp/. - Extracts each into
_temp/extracted_<msu>/. Nested archives (.cab,.psf) are unpacked. Forward / reverse deltas are applied throughDeltaApi. - Builds
prev,curr, andwinsxsPolars DataFrames; computes their filtered intersections. - For every unseen
(name, package), callsMODELS_GATHER_INFOto produce an 80-token description and embeds it intowindows.exe.desc.
- Downloads both
platform_internals:- Asks
MODELS_PLATFORM_INTERNALSto derive a similarity-search query from CVE metadata. - Pulls the top-10 candidates from
windows.exe.desc. - Asks
MODELS_DEFAULTto rescore them; the patched binaryclfs.systypically ranks at the top for CVE-2025-29824.
- Asks
- Routing filters candidates above
thresholds.candidates(default 7.5), patches their entries throughdelta_apply.patch_entryto materialize all three KB versions on disk, andSends one RE job per(primary, secondary)pair to the RE subgraph. reverse_engineering(per pair):- IDA produces
clfs.sys.BinExportfor each side. - BinDiff diffs them into
clfs.sys.<KB>.BinDiff. - For each function with
similarity < 1.0, IDA decompiles the address into__funcs__/<addr>.c(batched 500 funcs per IDA invocation per.i64). - Emits an
Artifactwith the diff and the changedFunctionMatchlist.
- IDA produces
vulnerability_research(per Artifact):- Reads each function's before / after
.cfiles; computes a unified diff. MODELS_REVERSE_ENGINEERINGscores each function's security relevancy.- Functions above
thresholds.security_modificationgo into the report-generation prompt;MODELS_RESEARCHERproduces a structuredVulnReport(found, confidence, report). - If the report's confidence >
thresholds.report→generate; else loop up toiter_remainingtimes on the remainder. generatepersists theReportintowindows.exe.rca.reports.
- Reads each function's before / after
finalizelogscve_run_complete. The orchestrator returns the final state.- CLI has previously installed a Rich progress reporter; its context manager
exits and
ctx.close()releases theDeltaApihandle.
A subsequent patchdiff-ai cve CVE-2025-29824 run finds the report in Chroma and
short-circuits at step 3, returning immediately.
The full step-by-step lives at
src/patchdiff_ai/platforms/add_platform.md.
Short version:
- Create a package
platforms/<name>/withprovider.py,<thing>.py(the per-versionPlatform),cli.py, and__init__.py. Useplatforms/linux/as the template — it's the canonical skeleton. - Implement the
Platformprotocol's four pipeline-facing methods on the per-version class:enrich_cve,gather_packages,candidate_prompts,candidate_metadata. - Implement the
PlatformProviderprotocol on the group-level class:cli_group,health_check,install,matches_native,matches_nvd,resolve. Wrap any sync HTTP calls insidematches_nativewithasyncio.to_threadso the parallel native round stays parallel. - Build the Click sub-group with
cve(using@cve_optionsfromcli/options.pyand delegating tocli/runner.run_single_cve),health-check,install. Add provider-specific commands as needed. - Register
MyProvider()inplatforms/__init__.py'sproviders()tuple. - If your platform needs new advisory fields, add them to
schemas/cve.pyas optional fields so the existing Windows path keeps working unchanged. - If your platform's candidate prompts need a different shape, add new
PromptIdentries inprompts/registry.pyand return them fromcandidate_prompts().
platforms/windows/ is the
real reference (provider + versioned plugin + Click group + cycle
helpers).
- Add a node function in
graphs/<name>/nodes.py'smake_nodes(ctx)factory. - Register the node in
graphs/<name>/graph.py'sbuild_<name>_graph(ctx). - Wire its edges. Use
add_conditional_edges(...)for branching,add_edge(...)for fixed transitions. - Extend the subgraph's
state.pywith whatever fields the new node reads / writes. For collection-style fields, useAnnotated[list[T], append_list].
- Create
graphs/<new>/{state.py, nodes.py, graph.py}. - Register the compiled graph in
graphs/pipeline/graph.pyas a node. - Add a new
Stagevalue and a newPipelineRouter.from_<previous>method wiring the transition.
- Drop a
build_<name>_chat(spec, creds)factory underllm/providers/. - Add a
Providerenum value and a credentials class inconfig/credentials.py. - Extend
MODEL_CATALOGinllm/catalog.py. - Wire
Provider.<NEW>intoModelRegistry._provider_availableand_build.
- Drop the wrapper under
tools/, built ontools/process.py'srun(). - Expose its executable path through
ToolPathsinconfig/tools.py. - Inject it via
AppContext.toolsinruntime/app_context.py. - Use the injected instance from nodes — never reach for a module-level singleton.
Set models.<purpose> in config.json (or MODELS_<PURPOSE>=<catalog-name> in env), or pass a --model flag through
the CLI command and into the run config; nodes call
ctx.registry.for_purpose(ModelPurpose.<X>), so the override is honored as long as
the model exists in the catalog and its provider has credentials.
For the higher-level summary, see readme.md. For the rationale behind specific decisions, see .plan/refactor-plan.md.