Lachesis
A compiler-precise code graph you can ask questions about: how data moves, who calls what, what reaches a sink. C, Python, and TypeScript, all in one graph.
Install with python -m pip install lachesis-cpg, then use import lachesis or the lachesis command.
A symbol index (LSP, ctags, SCIP) tells you where a name appears. Lachesis tells you how a value moves — does this request parameter reach that SQL call, which of these two near-identical functions checks its input first, what can flow into this buffer, where a C object is freed twice or used after free. It parses a codebase with real compilers, not regexes, builds one graph with a full dataflow layer (value-flow, points-to, taint, aliasing) and a native temporal pass for C object lifetimes, and answers questions from that graph — on the command line, as a Python library, or over MCP to an AI agent. Large, multi-language trees build as parallel shards linked into one graph within a bounded memory envelope.
A worked example: a Flask control plane where three handlers reach the same SQL sink unguarded while two siblings authorize first. Lachesis follows the value, flags the three, and names their guarded twins — see it live on the pull request →. Scan your own repo on every PR with the Lachesis Security Scan Action.
Quickstart
Install, then point it at a repo. One command builds and caches the graph and prints the leads — the reachable sensitive operations that no guard covers, each a question to investigate, not a verdict:
python -m pip install lachesis-cpg
lachesis ./my-project
The source can also be a git URL — lachesis https://github.com/owner/repo, optionally with
a #subdir fragment to scan one subtree of a monorepo. A URL is shallow-cloned to a temp
directory and removed when the scan finishes.
✓ compiling (0.7s)
2,677 nodes, 4,539 edges from typescript-compiler-api
✓ finding entrypoints that reach sensitive effects (0.1s)
2 leads (lens=all)
1. [0.810] handleWebhook (http/webhook.ts:10, route) -> findById(documentId) [database]
prove or kill: a caller that passes no recognized guard can read or write data
through findById(documentId) starting from handleWebhook at http/webhook.ts:10
2. [0.810] handleWebhook (http/webhook.ts:10, route) -> findById(invoiceId) [database]
unknown: this function branches on something; an owner/tenant comparison would not
be recognized as a guard by name and is not modeled here
That second lead is the point: handleWebhook reaches two near-identical database calls,
and Lachesis tells them apart by following the value, not by matching a name. To hand the
same codebase to an agent that can chase these down, serve it over MCP:
lachesis mcp ./my-project # zero-config: the agent builds and queries the graph itself
The first run of a project is slow; graphs are cached under ~/.lachesis/cache and every
run after is fast.
Three ways in: CLI, library, MCP
The same capability set is a command, a Python method, and an MCP tool — no surface is a second-class citizen, and none makes you hand-write a graph-loading script.
CLI — one lachesis entrypoint. scan is the front door; when you want to name a
graph and drive it yourself, the verbs mirror the three build passes:
lachesis build ./my-project graph.kuzu # pass 1 — the structural graph
lachesis enrich graph.kuzu # pass 2 — warm the dataflow + catalog sidecars
lachesis analyze graph.kuzu --summary # pass 3 — the leads, rolled up by bug shape
lachesis explain graph.kuzu tree.c:1487 # one call: the whole evidence chain for a site
The rest of the surface is verbs under the same entrypoint: candidates (the obligation
census over the whole taxonomy), query (targeted reads — find-entity, function,
value-history, call, security-path, handler-security, …), plan (a change-impact
capsule for one site), report and communities (rollups), trace (build a graph and
export a lachesis-explorer bundle.json — every sink family with the reachability cone that
feeds it), plus mcp, doctor, cache, concept-model, and completion. lachesis <verb> --help documents each.
For a large tree, build core-only and cap the wall clock — each frontend shard streams
straight into Kùzu instead of composing a graph-sized Python object, and enrich reads
the sidecars this leaves behind rather than re-parsing the source:
lachesis build ./my-project graph.kuzu --prune --timeout 3600
On a full libxml2 tree that cold build is ~28 s and ~1 GiB peak RSS across all three
languages. The streaming layout, sidecar formats, and memory/timing knobs are in
docs/scaling.md.
Library — a warm session: open (or build) once, ask many times, nothing recomputed between questions.
import lachesis
a = lachesis.Analysis.build("./my-project", "graph.kuzu", enrich=True)
leads = a.scan(hard_stop=120) # bounded scan → a LeadSet held in memory
print(leads.summary()) # {'total': ..., 'by_pattern': {...}, 'timed_out': False}
for lead in leads.near("tree.c", (1480, 1500)): # filter the held leads, no recompute
print(lead.pattern, lead.entry, lead.line)
print(a.explain_sink("tree.c", 1487)) # the whole evidence chain for one site
scan returns a LeadSet with .summary(), .by_pattern(), .by_function(),
.near() / .at(), .top(), .to_json(), and typed iteration — the leads stay in the
session, so a follow-up question is a filter, not a second pass. Bounded by default: with no hard_stop
it still caps its own wall clock and returns partial, flagged leads rather than hanging.
Runnable one-file scripts for each operation are in examples/.
MCP — every verb above is also a tool an agent drives directly (build_graph,
enrich, flow_pass, explain, and the in-memory leads_* queries) over the same warm
session. See MCP.
What you can ask
Once a graph is built, these are the moves — from the command line, the Analysis library,
or as MCP tools an agent drives directly:
| You want to know | The move |
|---|---|
| What is this subsystem built around? | hubs, the highest-degree functions (no name knowledge needed) |
| Where is this symbol? | search |
| Who calls this? What does it call? | callers, callees (direct and indirect dispatch) |
| Show me the actual source | read_body, exact bytes by offset |
| What's in this file or folder? | open_file, open_folder |
| Where does this value go? What feeds this sink? | flow, sources_of |
| Does this source reach that sink? | reaches, a labeled witness path or an honest "no" |
| What does this pointer point to? What aliases it? | points_to, aliases |
| Where does untrusted input reach a dangerous sink? | taint, source→sink witnesses folded from the Atropos catalog onto this graph's nodes |
| Is this C object freed twice, or used after it's freed? | the native temporal lifetime pass, a typestate matcher over C object lifecycles that confirms double-free / use-after-free with a path witness |
| Which entrypoints reach sensitive effects without a recognized guard? | scan, the leads with census/frontier counts (questions, not verdicts) |
| What are the leads, and where do they land? | analyze / candidates / leads_summary / leads_at, the obligation census held warm and filtered by pattern, function, or file:line |
| The full evidence for one site, in one call | explain, chaining census → candidate → provenance → guard → source |
| A shareable map of every sink family and what feeds it | trace, a lachesis-explorer bundle.json — each family with its reachability cone |
Every answer carries a confidence and an origin. An exact edge is resolved; a
conservative one is a deliberate over-approximation the tool tells you about rather than
hiding. You read the results as evidence, not as verdicts.
MCP
Use lachesis mcp from the same environment that built the graph. You can hand it an
absolute graph.kuzu path, but you don't have to: start it with no argument and the agent
builds its own graph on demand with build_graph — point it at a repo and it compiles,
caches, and attaches in one call (an unchanged tree is served from cache; refresh: true
forces a rebuild). Overlapping requests are serialized around the single store, so a
concurrent call can't tear the server down mid-flight.
One click (uses uvx, no install step):
Or configure any client by hand — drop one of these into your MCP client's config (Claude Desktop, Cursor, Claude Code). If the package is already installed:
{
"mcpServers": {
"lachesis": { "command": "lachesis", "args": ["mcp"] }
}
}
Or with no install step, letting uvx fetch it on first run:
{
"mcpServers": {
"lachesis": { "command": "uvx", "args": ["--from", "lachesis-cpg", "lachesis", "mcp"] }
}
}
Or as a container — no Python, Node, or clang on the host, all three frontends in the image:
{
"mcpServers": {
"lachesis": {
"command": "docker",
"args": ["run", "--rm", "-i", "-v", "/path/to/your/project:/src",
"ghcr.io/unboundcompute/lachesis:edge"]
}
}
}
Mount your project (here /src) and point build_graph at it. In VS Code use
${workspaceFolder} for the mount source. The image is published for linux/amd64 and
linux/arm64; :edge tracks main, and each release also publishes an :x.y.z tag.
More client and troubleshooting notes are in
docs/queries.md.
Languages
Three frontends, each backed by a real compiler or the language's own parser, never a heuristic grammar.
| Language | Engine | Extensions |
|---|---|---|
| TypeScript / JavaScript | the TypeScript compiler API, with the type checker | .ts .tsx .mts .cts .js .jsx |
| Python | CPython's own ast + symtable (standard library only) | .py .pyi |
| C | Clang, via its AST dump | .c .h |
A mixed tree is one graph, not three. Lachesis picks a frontend per file, composes the results into a single node and edge set, and runs the same analysis over all of it — a Python caller and a TypeScript callee sit in the same store and the same tools answer over both.
Two honest limits, stated up front: Python has no type checker, so it resolves attribute
calls lexically and says so (types: none); C reads one translation unit at a time, so it
won't follow a call through a function-pointer table it never sees. Each frontend declares
what it actually knows, and a validator holds it to that claim.
How it's built
Lachesis works in three passes, and each is a verb.
Pass 1 — build parses the source with real compilers into the core tier: syntax,
symbols, and calls. This is the fast part, and all most navigation needs.
Pass 2 — enrich materializes the dataflow tier — value-flow, points-to, taint,
aliasing — a pure function of the core graph, so it is never written at build time. You
rarely run it by hand: any query that needs value-flow folds in just the cone around its
seed and caches it beside the store, so nothing pays for a whole-graph pass it never asked
about. enrich is the one-shot "warm it all now" for a batch job, persisting the tier and
catalog bind as .dataflow.pb / .bind.pb sidecars so a later, fresh process opens warm.
Pass 3 — analyze runs the flow pass over the enriched graph and produces the leads:
safety-obligation sites, scored and matched against bug shapes. It is bounded — a
hard_stop budget caps the wall clock and returns partial leads with timed_out=True
rather than hanging, so a large graph can't stall a call. An empty result over a partial
run reads as not evaluated, never clean.
Two capabilities sit alongside the three passes:
Native temporal lifetime pass (C). Guard-and-taint shapes catch what reaches a sink, but a double-free or use-after-free is a property of an object's lifecycle, not a single node. A native pipeline handles it — a Clang frontend emits per-object lifecycle events (alloc / free / use / return), a Rust typestate kernel matches those event streams, and the result is folded back onto the graph as confirmed temporal candidates carrying a path witness. It confirms double-free and use-after-free on C today without false positives on the clean control paths.
Federated sharding. A large, multi-language tree is built as parallel shards — each frontend streams straight into its own store — that are then linked into one graph by cross-shard symbol (USR) resolution, so value-flow, callers, and callees still cross shard boundaries. This keeps a monorepo-scale build inside a bounded memory envelope instead of composing a graph-sized object in memory.
source tree
|
v build (pass 1)
frontends real compilers parse each language into
| syntax, symbols, calls (the core tier)
v enrich (pass 2, on demand or all-at-once)
kuzu store staged Parquet, bulk-copied into an embedded
| columnar graph DB; dataflow tier folded in as a
| cone around each seed, cached beside the store
v analyze (pass 3, bounded)
nav (+ MCP) hubs, search, callers/callees, read_body, flow,
reaches, sources_of, points_to, aliases, scan,
explain, leads — over one warm session
graph.kuzu is a directory: the embedded database plus a manifest. That is the graph.
Every tool reads it directly, and lachesis mcp serves the same tools over stdio for any
MCP-capable client. Large-build, monorepo, and CI tuning — including cold-build memory and
timing on a full libxml2 graph — live in docs/scaling.md; the graph
model is in docs/graph-model.md.
Install
python -m pip install lachesis-cpg
The release-tested Python window is 3.10–3.12 (the CI matrix). Python analysis needs
nothing beyond the package; TypeScript/JavaScript builds need node on PATH and C
builds need clang — a missing one comes back as an actionable error, not a crash.
To work from a clone (the contributor workflow, and how you build the TypeScript frontend from checked-out sources):
git clone https://github.com/UnboundCompute/lachesis && cd lachesis
python -m pip install --upgrade pip # editable installs need pip >= 21.3
python -m pip install -e ".[dev]" # builder, nav, MCP server, tests
npm ci # install the locked TypeScript compiler dependency
cargo build --release --manifest-path native/clang_frontend/Cargo.toml
Runtime dependencies are just kuzu and pyarrow; everything else is standard library.
Node 20+ must be on your PATH for the TS frontend. In a source checkout, the C frontend
automatically uses the release Rust binary above; without that binary it uses the
portable Clang frontend. Run the frontend parity gate CI uses with make check.
Semantic concept_search is optional and separate — opt in with
pip install -e ".[concept-search]", then lachesis concept-model download.
Where to go next
examples/: a five-minute walkthrough on a bundled fixture, plus one runnable.pyscript per library operation.docs/graph-model.md: what's in the graph — node kinds, edge kinds, and tiers.docs/queries.md: every way to ask a question, bothlachesis queryand the MCP tools.docs/scaling.md: large-build, monorepo, and CI-runner tuning; managing the local graph cache.
Roadmap
Recently shipped:
- Native temporal lifetime detection (C). A Clang frontend + Rust typestate kernel confirm double-free and use-after-free on C object lifecycles, with a path witness and no false positives on the clean control paths — a class of bug the guard/taint shapes structurally can't see.
- Federated sharding. Large, multi-language trees build as parallel per-frontend shards linked into one graph by cross-shard symbol (USR) resolution, keeping a monorepo-scale build inside a bounded memory envelope while value-flow and calls still cross shard boundaries.
- Scan a git URL directly.
lachesis https://…#subdirshallow-clones, scans one subtree of a monorepo, and cleans up after itself — no manual checkout. - Graph-first Explorer bundle.
lachesis traceexports a lachesis-explorerbundle.json: every sink family with the reachability cone that feeds it, in the shape the explorer renders. - One reader, three front doors. The
lachesis.Analysislibrary class is the single implementation; alachesis <verb>subcommand and an MCP tool sit over each method — no hand-written graph-loading script on any surface. - Bounded analysis. Pass 3 takes a
hard_stopbudget and returns partial, flagged leads instead of hanging; the census a graph pays for once is cached as a sidecar so the next process opens warm. - Zero-config MCP.
lachesis mcpstarts with no graph path;build_graphcompiles, caches, and attaches on demand, and overlapping requests are serialized around the store.
Near-term, roughly in order:
- Cross-function temporal shapes. Extend the lifetime pass past a single function so free-in-one / use-in-another patterns match across a call seam, riding the value-flow edge the graph already carries.
- Bounded security signal. Reworking the guard-analysis tools to fold the same per-seed, on-demand cone the dataflow tools already use, so they run on a large graph without a whole-graph pass.
- The reachability query, first-class. "Can attacker input reach this sink" as a single call returning a witness path or a bounded no, across file, package, and language boundaries.
Status
Lachesis is early and moving fast. The graph model, the store, the navigation and MCP layer,
and the native temporal lifetime pass work today and are held to a parity test suite that
checks the columnar store answers every tool identically to the same graph held whole in
memory. The temporal pass is C-only for now and matches within a single function; one known
false positive (a leak reported on an object that is both freed and used-after-free) is
tracked. The schema and tool set may still shift before 1.0; the
CHANGELOG calls out changes explicitly.
License
AGPL-3.0. See LICENSE. You're free to use, study, modify, and share it,
commercially included; run a modified version as a network service and you make your
modified source available to its users. If that doesn't fit — say, embedding in a closed
product — a separate commercial license may be available. See
CONTRIBUTING.md or open an issue.
Security
Found a vulnerability? Please don't open a public issue; see SECURITY.md
for private reporting.