I/O and Data-Flow Edges

August 12, 2026 · View on GitHub

This page explains the three relationships that model how code touches external resources and how values move between them: READS_FROM, WRITES_TO, and FLOWS_TO. For the one-line schema summary see Graph Schema; this page is the detailed reference.

All three are opt-in. They belong to the io capture group, which is excluded from the default capture set, so a default build emits none of them and does no extra work. Enable them with the io capture group (see Configuration).

The mental model: taint

The design borrows the vocabulary of taint analysis, a standard technique in program analysis. The idea is a drop of dye in water: mark a value where it enters the program, then follow it wherever it spreads.

  • A source is where a value enters from the outside world (reading an environment variable, a file, a socket). A value read from a source is tainted — it carries a note about where it came from.
  • Propagation is how that note travels: through assignments, into function calls as arguments, and back out through return values.
  • A sink is where a value leaves for the outside world (writing to standard output, to a file, over the network).

"Taint" carries no other meaning here. When the docs say "x is tainted by ENV::K", read it as "x holds a value that originated at the ENV::K resource, and the analysis is tracking it until it is written out or overwritten." Turning that tracking into graph edges is what lets a single query answer does anything from this source reach that sink?

Resource nodes

Sources and sinks are represented by synthetic Resource nodes. A resource qualified name has the form resource::<KIND>::<identity>:

  • identity is the static string literal target when one is available (a file path, an environment variable name) and <dynamic> when the target is not a compile-time constant (for example open(path) where path is a variable, or standard streams that have no literal target).
  • KIND is one of eight values. The table shows what each represents and, for the current Python registry, which calls produce it and in which direction.
KINDRepresentsDetected from (Python)Direction
FILEA file on diskopen(...) and its handle methods (.read, .write, …); json.load / json.dumpread + write
ENVAn environment variableos.getenv(...), os.environ.get(...)read
NETWORKA network endpoint / URLrequests.get / .head, urllib.request.urlopen, httpx.get (read); requests.post / .put / .patch / .delete, httpx.post (write); httpx.Client / AsyncClient and aiohttp.ClientSession handle methodsread + write
DATABASEA database connectionsqlite3.connect(...) handle methods (.execute, .fetchone, .commit, …)read + write
SOCKETA network socketsocket.socket(...) handle methods (.recv, .send, …)read + write
STDOUTStandard outputprint(...)write
STDINStandard input(defined in the schema; no Python source registered yet)
STDERRStandard error(defined in the schema; no Python source registered yet)

Example: os.getenv("K") refers to resource::ENV::K; print(x) refers to resource::STDOUT::<dynamic>. The registry is extended in codebase_rag/parsers/io_access/registry.py. The Python registry does not yet register STDIN or STDERR sources/sinks, but other languages emit them (for example C scanf, C++ std::cerr, Java System.err, C# Console.Error).

READS_FROM and WRITES_TO

These connect a callable to a resource it touches. The direction is decided by the call and (for file handles) its mode:

CodeEdge
os.getenv("K")Function -READS_FROM-> Resource(ENV::K)
print(x)Function -WRITES_TO-> Resource(STDOUT::<dynamic>)
open("out.txt", "w")Function -WRITES_TO-> Resource(FILE::out.txt)
open("cfg.yaml")Function -READS_FROM-> Resource(FILE::cfg.yaml)

The source of the edge is the immediate enclosing scope of the call — the Function, Method, or Module that directly contains it. A read or write inside a nested function is attributed to that nested function alone, never bubbled up to an enclosing function or the module. (This matches how CALLS is attributed, and how FLOWS_TO treats nested scopes below.)

FLOWS_TO

FLOWS_TO records value flow: that a value moved from one place to another. It turns provenance questions into plain graph reachability. Every FLOWS_TO edge carries two properties that say what kind of flow it is and how the value travelled:

FLOWS_TO  ·  <kind>  ·  <via>
   │            │          │
relationship   which of    the channel the
type (always   the three   value crossed
FLOWS_TO)      shapes      through

kind is the primary category; via is the precise channel, present on two of the three shapes. All three below come from one function body:

def build():
    return os.getenv("K")      # build returns a value read from ENV::K

def forward(v):
    print(v)

def leak():
    x = os.getenv("K")         # x now carries ENV::K
    print(x)                   # shape 1
    t = os.getenv("T")
    forward(t)                 # shape 2
    r = build()
    print(r)                   # shape 3

The three FLOWS_TO edges that body produces:

ENV::K FLOWS_TO · resource STDOUT::<dynamic>
flow.leak FLOWS_TO · arg · arg:0 flow.forward
flow.build FLOWS_TO · return · return flow.leak
resource::… resource node module.fn code node label reads FLOWS_TO · kind · via

Shape 1 — resource to resource (kind = resource)

A value read from one resource reaches a write to another within a function body. No via.

Resource(ENV::K) -FLOWS_TO {kind: resource}-> Resource(STDOUT::<dynamic>)

x is read from ENV::K, then passed to print(x), which writes STDOUT. Both endpoints are resource nodes. This is the leak/provenance answer: a value from the environment reached standard output.

Shape 2 — caller to callee (kind = arg)

A tainted local value is passed as an argument into a first-party callee. via names the conduit.

Function(leak) -FLOWS_TO {kind: arg, via: arg:0}-> Function(forward)

t (tainted by ENV::T) is passed to forward(t) as the 0th positional argument, so via = arg:0. A keyword call such as forward(v=t) records via = kw:v instead. Both endpoints are code nodes; the edge records taint crossing a call boundary into the callee.

Shape 3 — callee to caller (kind = return, via = return)

A callee whose return value is tainted flows that value back to its caller.

Function(build) -FLOWS_TO {kind: return, via: return}-> Function(leak)

build() returns os.getenv("K"), and leak does r = build(), so taint crosses the call boundary out of the callee. via = return is simply the channel name. This edge is emitted both when the returned value is assigned (r = build()) and when it is returned directly (return build()).

Which way the arrow points

The arg and return edges can look confusing side by side, because they point in opposite directions even though the caller is the same in both:

leak -FLOWS_TO {kind: arg}->    forward     (leak passes t INTO forward)
build -FLOWS_TO {kind: return}-> leak        (build hands a value BACK to leak)

Both come from leak's body — leak is the caller in both. The arrow points opposite ways because the value travels opposite ways across the call boundary. Picture each function as a box with input slots on the front and one output chute on the back:

   t ──▶│ forward │            │ build │──▶ r
        └─────────┘            └───────┘
   value goes IN               value comes OUT
   caller → callee (arg)       callee → caller (return)

leak operates both boxes: it pushes t into forward's input slot (leak → forward), and it catches what build's chute produces (build → leak).

Note that the assignment in r = build() is not what flips the direction. A return value flows out of the callee regardless; the r = only gives that out-flowing value a name so it can be tracked further downstream (which is how print(r) later completes the ENV::K → STDOUT resource flow). The rule is simply:

Direction follows the value. In as an argument → caller → callee (arg). Out as a return → callee → caller (return).

Reading an edge

Read any FLOWS_TO edge as a sentence:

a value flowed from left to right, and it was a <kind> flow that travelled <via>.

Keeping kind and via as properties on a single relationship type means one query — MATCH ()-[:FLOWS_TO]->() — walks the entire data-flow graph, and you filter on r.kind / r.via only when you need the detail.

kind values

kind is one of exactly three values:

kindEndpointsMeaningvia
resourceResourceResourceA value read from one resource reaches a write to another.(none)
argcode → codeA tainted value is passed into a callee as an argument.arg:<index> or kw:<name>
returncode → codeA callee's tainted return value flows back to its caller.return

"code" endpoints are Module, Function, or Method nodes.

via values

via names the exact channel the value crossed. It is present only on arg and return edges (a resource edge has no via):

viaAppears onMeaning
arg:<index>kind = argPassed as a positional argument; <index> is the 0-based position, e.g. arg:0 for the first argument.
kw:<name>kind = argPassed as a keyword argument; <name> is the parameter name, e.g. kw:token for forward(token=t).
returnkind = returnHanded back through the callee's return statement.

Propagation and kill rules

Within a function body, taint moves and disappears by these rules:

  • Copy. b = a copies a's taint (and its origin resource) to b.
  • Rebind to a new source. x = os.getenv("B") after x = os.getenv("A") makes x carry ENV::B; the discarded ENV::A no longer flows from x.
  • Kill. Assigning a tainted variable to something clean removes its taint: x = "safe" or x = <untainted variable> means x is no longer tracked, so a later print(x) produces no resource flow. The READS_FROM / WRITES_TO edges for the individual calls are still recorded; only the false data-flow edge is suppressed.
  • Co-occurrence is not flow. An unrelated read sitting next to an untainted call produces no FLOWS_TO edge. Reading ENV::K in the same function that calls helper(u) with an untainted u does not connect the two.

How a chain is resolved (the forward pass)

A multi-hop chain like a = getenv(...); b = a; c = b; print(c) might look as if it needs a backward search from the sink (print) down through c → b → a to the source. It does not. The analyser makes a single forward pass, top to bottom, carrying one live table:

tainted = { variable name → the origin resource it currently carries }

The table's value is the origin resource, not a pointer to the previous variable, so each assignment copies the origin forward:

a = os.getenv("K")   # getenv is a source     → tainted = { a: ENV::K }
b = a                # 'a' is tainted, copy it → tainted = { a: ENV::K, b: ENV::K }
c = b                # 'b' is tainted, copy it → tainted = { a: ENV::K, b: ENV::K, c: ENV::K }
print(c)             # sink; look up 'c' → ENV::K → emit  ENV::K → STDOUT

By the time the sink is reached, the origin is already known by an O(1) lookup — there is no sink-to-source traversal. The intermediate variables a, b, c are not graph nodes; they exist only in this table, and the chain collapses to a single edge between the two resource endpoints it connects.

Because the origin rides forward at every =, chain length is irrelevant — one hop or fifty, it is still one sweep and one edge. And if any hop is overwritten with something clean, that variable drops out of the table (the kill rule above), so the sink finds nothing and no edge is drawn.

Scope attribution

Each function, method, and nested definition is analysed as its own unit. A read, write, or flow is attributed to the immediate scope that contains it, never to an enclosing scope. In particular a value tainted inside a nested function does not leak into the outer function's flow, and the outer function's own reads and writes are unaffected.

Opt-in and endpoints

FLOWS_TO and its Resource endpoints are only produced when the io capture group is enabled. When enabled, the resource endpoints of a FLOWS_TO edge are always ensured as nodes first, so an edge never dangles to a missing node — even if READS_FROM / WRITES_TO themselves are filtered out by a narrower capture selection.

Example queries

Once a graph is built with the io capture group, these Cypher queries answer the provenance questions the edges are designed for:

// Every value that flows from an environment variable to standard output.
MATCH (a:Resource)-[r:FLOWS_TO {kind: 'resource'}]->(b:Resource)
WHERE a.kind = 'ENV' AND b.kind = 'STDOUT'
RETURN a.qualified_name, b.qualified_name;

// Multi-hop reachability: does any source reach any sink across calls?
MATCH p = (src:Resource)-[:FLOWS_TO*1..8]->(dst:Resource)
RETURN p;

// Which callables read a given resource directly.
MATCH (fn)-[:READS_FROM]->(r:Resource {qualified_name: 'resource::ENV::K'})
RETURN fn.qualified_name;

Cross-scope handle resolution

A resource handle bound in one scope and used in another is resolved against the scope that constructed it, so the I/O is credited to the scope that runs it:

  • Instance attributes. self.conn = sqlite3.connect(...) in __init__ (or any method) is visible to every other method, so self.conn.execute(...) in a different method emits the DATABASE edge on that method.
  • Enclosing locals. A module-level or outer-function conn = sqlite3.connect(...) is visible to nested functions that use it. A same-named local rebind shadows the inherited handle.

Re-exported modules

Source and handle detection matches the canonical dotted callee even when the module is re-exported under its own name. A project that does from .utils import sqlite3 (a common stdlib/pysqlite3 shim) still has its sqlite3.connect(...) recognised as a DATABASE handle.

Scope of the current phase

FLOWS_TO is intentionally conservative in this phase:

  • Value flow inside a function body is tracked by an intra-procedural walk. A callee returning different sources on different branches carries every origin to its callers. It is not path-sensitive: a kill on one branch of an if/else drops taint conservatively.
  • Return taint composes transitively across functions and files. Per-function summaries are resolved by a worklist fixpoint once every file has been walked, so a callee defined after (or in a different file from) its caller is still known to return a tainted value at the caller's site.
  • Forward argument taint composes into callee sinks for Python and the lean-walk languages with a parameter-name extractor (Go, JavaScript, TypeScript/TSX, C++): a parameter that reaches a write sink inside its body is recorded as a per-function parameter-sink summary (closed over transitive parameter hand-offs by the same finalize fixpoint), so a tainted argument passed at a call site emits the full resource -> resource flow even when the source and the sink live in different bodies — the logging-wrapper case secret = getenv('K'); log_it(secret) with log_it(m): logger.info(m) connects ENV to STDOUT. Only resolved callees participate; there are still no Parameter nodes and no SSA-level precision. Java and C# parse into the graph but have no parameter-name extractor yet, so their positional composition stays inert until one is added.
  • Forward argument taint also composes through a callee's return value for Python (pass-through helpers such as def redact(v): return v): a parameter that reaches the function's return — directly or transitively through return other(p) and pass-through chains — is closed over by the same finalize fixpoint, and a call site passing a tainted argument into such a parameter folds that argument's origins into the callee's return summary, so a caller consuming the return (y = redact(secret); print(y)) resolves the secret to the sink. This return composition is Python-only; the lean walks forward taint into callee sinks (above) but not yet through a callee's return.
  • The kind = arg edge itself is still recorded one level deep — it marks that a tainted value reached a call — and is emitted alongside the forward composition above. Sources and sinks are direct I/O calls from the registry.
  • The source/sink registry covers Python, JavaScript, TypeScript (including TSX), Go, Java, Rust, C, C++, C#, Lua, PHP, and Dart; a language not in the registry emits no I/O or flow edges until its table is added. PHP models $_GET/$_POST/ $_REQUEST/$_COOKIE (untrusted HTTP input → NETWORK) and $_ENV/$_SERVER (→ ENV) superglobal sources, getenv/file_get_contents reads, file_put_contents and echo/print (keyword STDOUT sinks) writes, and fopen + arg-shaped fwrite/fputs handle writes.
  • Handle-based writes in the lean (non-Python) FLOWS_TO walk are being taught incrementally (issue #1204). Go, Rust, Java, C#, JS/TS, and Lua now track handle bindings, so a taint written through a file/socket handle (f := os.Create(p); f.Write(x), Rust let mut f = File::create(p)?; f.write_all(s.as_bytes())?, Java new FileWriter(p).write(s) — including the wrapper new BufferedWriter(new FileWriter(p)) and factory Files.newBufferedWriter(Path.of(p)) forms — C# new StreamWriter(p).Write(s), JS/TS fs.createWriteStream(p).write(s), or Lua local f = io.open(p, "w"); f:write(s)) emits a flow edge to the handle's resource — path-sensitively (a rebind on one branch writes to all feasible resources) and mode-aware (a read-only os.Open/File::open/new FileReader/ new StreamReader handle is not a write sink; Lua's io.open mode is unknowable to the binder, so it stays a sound may-write gated by the method table). C and C++ cover the arg-shaped libc FILE* API, where the handle rides an argument rather than a receiver (FILE *f = fopen(p, "w"); fwrite(x, 1, n, f), fprintf(f, fmt, x), or fprintf(stderr, fmt, x) to a pre-bound std stream): the tainted payload arguments flow to the handle's resource, an untracked FILE* degrading to FILE:<dynamic>. Each arg sink pins its payload position — fprintf forwards every non-handle argument (format + varargs), but fwrite(buffer, size, count, stream) forwards only buffer (arg 0), so a tainted size/count is control metadata, not a leak. The libc model applies only to unresolved calls, so a project-defined function that happens to be named fwrite/fprintf is analysed as itself. C++ also binds type-declaration stream handles (std::ofstream out(p)), so a tainted out << x (stream insertion) or out.write(..) reaches the file. Both new-shaped constructors and their wrapper / identity-carrier (new PrintWriter(new File(p))) forms resolve, reusing the same registry tables the READS_FROM/WRITES_TO walk uses. Every catalogued handle-write shape across the lean languages now emits a flow edge rather than a false NO_FLOW.

These are deliberate ceilings, chosen so the feature is correct and cheap where it applies rather than broad and noisy.

Language coverage

FLOWS_TO covers 13 of the 14 supported languages — every language whose source/sink table is registered in FLOW_REGISTERED_LANGUAGES (codebase_rag/parsers/io_access/registry.py). Python uses the deep, path-sensitive walk; the rest use the descriptor-driven lean walk. A language outside this set is still parsed into the graph — it simply emits no FLOWS_TO edges, so a reachability question over it returns UNKNOWN rather than NO_FLOW (see the three-verdict query below).

LanguageFLOWS_TOWalk
Pythondeep, path-sensitive
JavaScriptlean descriptor
TypeScriptlean descriptor
TSXlean descriptor
Golean descriptor
Javalean descriptor
Rustlean descriptor
C++lean descriptor
Clean descriptor
C#lean descriptor
Lualean descriptor
PHPlean descriptor
Scalanot covered — no sink table
Dartlean descriptor + Dart selector-chain path

Coverage metadata and the three-verdict query

An empty flow result is ambiguous on its own: "no flow exists" and "the flow sits outside what the analysis covers" look identical, and for assurance questions an absent path must never be read as a pass. Two mechanisms make the distinction queryable:

  • Every file-backed Module node carries a flow_covered boolean: true when the module's language is in the source/sink registry and the FLOWS_TO capture group was enabled at indexing time. A bodied inline mod stamps the same value as its file; inline Module nodes that keep a synthetic inline_module_* path have no independent coverage and are excluded from gap reporting rather than counted as gaps. Directly queryable in Cypher.
  • A source-to-sink reachability question, exposed as the flow_verdict MCP tool, answers with one of three verdicts:
    • FOUND — a FLOWS_TO path exists; the qualified-name path is returned.
    • NO_FLOW — no path, and every module of the project was inside analysed coverage.
    • UNKNOWN — no path was found, but part of the project sits outside coverage; the uncovered files are named.

The coverage read is deliberately project-wide rather than restricted to the query's reachable surface: without path sensitivity, a flow through an uncovered file cannot be ruled out from the covered part of the graph, so narrowing the check would manufacture false NO_FLOW verdicts. Reachability itself runs client-side over a linear scan of the project's FLOWS_TO edges, the same discipline as dead-code detection.