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>:
identityis 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 exampleopen(path)wherepathis a variable, or standard streams that have no literal target).KINDis one of eight values. The table shows what each represents and, for the current Python registry, which calls produce it and in which direction.
KIND | Represents | Detected from (Python) | Direction |
|---|---|---|---|
FILE | A file on disk | open(...) and its handle methods (.read, .write, …); json.load / json.dump | read + write |
ENV | An environment variable | os.getenv(...), os.environ.get(...) | read |
NETWORK | A network endpoint / URL | requests.get / .head, urllib.request.urlopen, httpx.get (read); requests.post / .put / .patch / .delete, httpx.post (write); httpx.Client / AsyncClient and aiohttp.ClientSession handle methods | read + write |
DATABASE | A database connection | sqlite3.connect(...) handle methods (.execute, .fetchone, .commit, …) | read + write |
SOCKET | A network socket | socket.socket(...) handle methods (.recv, .send, …) | read + write |
STDOUT | Standard output | print(...) | write |
STDIN | Standard input | (defined in the schema; no Python source registered yet) | — |
STDERR | Standard 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:
| Code | Edge |
|---|---|
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:
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:
kind | Endpoints | Meaning | via |
|---|---|---|---|
resource | Resource → Resource | A value read from one resource reaches a write to another. | (none) |
arg | code → code | A tainted value is passed into a callee as an argument. | arg:<index> or kw:<name> |
return | code → code | A 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):
via | Appears on | Meaning |
|---|---|---|
arg:<index> | kind = arg | Passed as a positional argument; <index> is the 0-based position, e.g. arg:0 for the first argument. |
kw:<name> | kind = arg | Passed as a keyword argument; <name> is the parameter name, e.g. kw:token for forward(token=t). |
return | kind = return | Handed back through the callee's return statement. |
Propagation and kill rules
Within a function body, taint moves and disappears by these rules:
- Copy.
b = acopiesa's taint (and its origin resource) tob. - Rebind to a new source.
x = os.getenv("B")afterx = os.getenv("A")makesxcarryENV::B; the discardedENV::Ano longer flows fromx. - Kill. Assigning a tainted variable to something clean removes its taint:
x = "safe"orx = <untainted variable>meansxis no longer tracked, so a laterprint(x)produces no resource flow. TheREADS_FROM/WRITES_TOedges 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_TOedge. ReadingENV::Kin the same function that callshelper(u)with an untaintedudoes 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, soself.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/elsedrops 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 -> resourceflow even when the source and the sink live in different bodies — the logging-wrapper casesecret = getenv('K'); log_it(secret)withlog_it(m): logger.info(m)connects ENV to STDOUT. Only resolved callees participate; there are still noParameternodes 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 throughreturn 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 = argedge 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_contentsreads,file_put_contentsandecho/print(keyword STDOUT sinks) writes, andfopen+ arg-shapedfwrite/fputshandle writes. - Handle-based writes in the lean (non-Python)
FLOWS_TOwalk 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), Rustlet mut f = File::create(p)?; f.write_all(s.as_bytes())?, Javanew FileWriter(p).write(s)— including the wrappernew BufferedWriter(new FileWriter(p))and factoryFiles.newBufferedWriter(Path.of(p))forms — C#new StreamWriter(p).Write(s), JS/TSfs.createWriteStream(p).write(s), or Lualocal 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-onlyos.Open/File::open/new FileReader/new StreamReaderhandle is not a write sink; Lua'sio.openmode is unknowable to the binder, so it stays a sound may-write gated by the method table). C and C++ cover the arg-shaped libcFILE*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), orfprintf(stderr, fmt, x)to a pre-bound std stream): the tainted payload arguments flow to the handle's resource, an untrackedFILE*degrading toFILE:<dynamic>. Each arg sink pins its payload position —fprintfforwards every non-handle argument (format + varargs), butfwrite(buffer, size, count, stream)forwards onlybuffer(arg 0), so a taintedsize/countis control metadata, not a leak. The libc model applies only to unresolved calls, so a project-defined function that happens to be namedfwrite/fprintfis analysed as itself. C++ also binds type-declaration stream handles (std::ofstream out(p)), so a taintedout << x(stream insertion) orout.write(..)reaches the file. Bothnew-shaped constructors and their wrapper / identity-carrier (new PrintWriter(new File(p))) forms resolve, reusing the same registry tables theREADS_FROM/WRITES_TOwalk uses. Every catalogued handle-write shape across the lean languages now emits a flow edge rather than a falseNO_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).
| Language | FLOWS_TO | Walk |
|---|---|---|
| Python | ✅ | deep, path-sensitive |
| JavaScript | ✅ | lean descriptor |
| TypeScript | ✅ | lean descriptor |
| TSX | ✅ | lean descriptor |
| Go | ✅ | lean descriptor |
| Java | ✅ | lean descriptor |
| Rust | ✅ | lean descriptor |
| C++ | ✅ | lean descriptor |
| C | ✅ | lean descriptor |
| C# | ✅ | lean descriptor |
| Lua | ✅ | lean descriptor |
| PHP | ✅ | lean descriptor |
| Scala | ❌ | not covered — no sink table |
| Dart | ✅ | lean 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
Modulenode carries aflow_coveredboolean:truewhen the module's language is in the source/sink registry and theFLOWS_TOcapture group was enabled at indexing time. A bodied inlinemodstamps the same value as its file; inline Module nodes that keep a syntheticinline_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_verdictMCP tool, answers with one of three verdicts:FOUND— aFLOWS_TOpath 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.