--- description: "How READS_FROM, WRITES_TO, and FLOWS_TO model I/O and value flow, and how to read their edge properties." --- # I/O and Data-Flow Edges 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](graph-schema.md); 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](../getting-started/configuration.md)). ## 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::::`: - `identity` is the static string literal target when one is available (a file path, an environment variable name) and `` 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. | `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::`. 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::)` | | `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 · · │ │ │ 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: ```python 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::) ``` `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 `` flow that > travelled ``. 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:` or `kw:` | | `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:` | `kind = arg` | Passed as a positional argument; `` is the 0-based position, e.g. `arg:0` for the first argument. | | `kw:` | `kind = arg` | Passed as a keyword argument; `` 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 = 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 = ` 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: ```cypher // 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#, and Lua; a language not in the registry emits no I/O or flow edges until its table is added. - The lean (non-Python) `FLOWS_TO` walk matches **direct call sinks only**; a taint that reaches a resource through a handle method (`os.Create(p).Write(x)`, `io.open(p):write(x)`, `File::create(p).write(x)`) emits no flow edge, uniformly across every lean language — the handle-tracking tables feed the `READS_FROM`/ `WRITES_TO` walk, not the flow walk. So a handle-only write leak reads as `NO_FLOW` rather than `UNKNOWN` in a fully covered project; teaching the flow walk to track handle bindings (cross-language) is tracked in #1204. This bites Lua hardest, since Lua has no direct file-write sink at all. 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 **11 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 | ❌ | not covered — no sink table | | Scala | ❌ | not covered — no sink table | | Dart | ❌ | not covered — no sink table | ## 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.