Skip to content

Commit 57ade4c

Browse files
tob-scott-aclaude
andauthored
feat(query): expose hidden QueryEngine APIs (#12)
Five methods existed in GraphStore but had no public-facing analogue, forcing skills to reach into private internals (`engine._store`) or drop into Python to compute answers Trailmark already knew: - ancestors_of(name): every function/method that can transitively reach the named node. The dual of callees_of, extended transitively. Upward slicing for audit: "given this sink, who could hit it?" - reachable_from(name): transitive closure of callees_of. - entrypoint_paths_to(name): call paths from any entrypoint to a sink. Answers the canonical attack-surface question; pairs with the recently-added entrypoint detection. - nodes_with_annotation(kind): slice the graph by audit state (e.g. all nodes tagged FINDING). - functions_that_raise(exception_name): consumes the parser-populated but previously-unqueried `exception_types` field on CodeUnit. 12 new tests covering direct/transitive behavior, unknown-node cases, and the module/contains-edge interaction in ancestor queries. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ebdfe1a commit 57ade4c

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

src/trailmark/query/api.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ def callees_of(self, name: str) -> list[dict[str, Any]]:
109109
return []
110110
return [_unit_to_dict(u) for u in self._store.callees_of(node_id)]
111111

112+
def ancestors_of(self, name: str) -> list[dict[str, Any]]:
113+
"""Find every function/method that can transitively reach ``name``.
114+
115+
The dual of ``callees_of`` extended transitively: given a sensitive
116+
sink, this surfaces every function that could eventually call it,
117+
directly or indirectly. Useful for upward slicing during audits.
118+
"""
119+
node_id = self._store.find_node_id(name)
120+
if node_id is None:
121+
return []
122+
ancestor_ids = self._store.ancestors_of(node_id)
123+
result: list[dict[str, Any]] = []
124+
for aid in ancestor_ids:
125+
unit = self._store._graph.nodes.get(aid) # noqa: SLF001
126+
if unit is not None:
127+
result.append(_unit_to_dict(unit))
128+
return result
129+
130+
def reachable_from(self, name: str) -> list[dict[str, Any]]:
131+
"""Find every function/method transitively reachable from ``name``.
132+
133+
The transitive closure of ``callees_of``.
134+
"""
135+
node_id = self._store.find_node_id(name)
136+
if node_id is None:
137+
return []
138+
reachable_ids = self._store.reachable_from(node_id)
139+
result: list[dict[str, Any]] = []
140+
for rid in reachable_ids:
141+
unit = self._store._graph.nodes.get(rid) # noqa: SLF001
142+
if unit is not None:
143+
result.append(_unit_to_dict(unit))
144+
return result
145+
112146
def paths_between(
113147
self,
114148
src: str,
@@ -121,6 +155,48 @@ def paths_between(
121155
return []
122156
return self._store.paths_between(src_id, dst_id)
123157

158+
def entrypoint_paths_to(
159+
self,
160+
name: str,
161+
max_depth: int = 20,
162+
) -> list[list[str]]:
163+
"""Find call paths from any entrypoint to ``name``.
164+
165+
Answers the canonical attack-surface question: "given this sink,
166+
what concrete entrypoint paths can reach it?" Returns a list of
167+
id-path lists, one per reachable entrypoint.
168+
"""
169+
node_id = self._store.find_node_id(name)
170+
if node_id is None:
171+
return []
172+
return self._store.entrypoint_paths_to(node_id, max_depth=max_depth)
173+
174+
def nodes_with_annotation(
175+
self,
176+
kind: AnnotationKind,
177+
) -> list[dict[str, Any]]:
178+
"""Return every node tagged with the given annotation kind."""
179+
return [_unit_to_dict(u) for u in self._store.nodes_with_annotation(kind)]
180+
181+
def functions_that_raise(
182+
self,
183+
exception_name: str,
184+
) -> list[dict[str, Any]]:
185+
"""Return functions/methods whose parser-detected exception list
186+
includes the named exception.
187+
188+
Looks at the ``exception_types`` field parsers populate when they
189+
extract ``raise``/``throw`` statements. Match is by type name
190+
(``TypeRef.name``) — modules/generics are ignored.
191+
"""
192+
result: list[dict[str, Any]] = []
193+
for unit in self._store._graph.nodes.values(): # noqa: SLF001
194+
for exc in unit.exception_types:
195+
if exc.name == exception_name:
196+
result.append(_unit_to_dict(unit))
197+
break
198+
return result
199+
124200
def attack_surface(self) -> list[dict[str, Any]]:
125201
"""List all entrypoints with their trust levels."""
126202
return [

tests/test_hidden_apis.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Tests for the newly exposed QueryEngine methods.
2+
3+
Covers ``ancestors_of``, ``reachable_from``, ``entrypoint_paths_to``,
4+
``nodes_with_annotation``, and ``functions_that_raise`` — methods that
5+
existed in ``GraphStore`` but had no public-facing analogue.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
from trailmark.models.annotations import AnnotationKind
13+
from trailmark.query.api import QueryEngine
14+
15+
16+
class TestAncestorsOf:
17+
def test_direct_caller_is_ancestor(self, tmp_path: Path) -> None:
18+
(tmp_path / "app.py").write_text(
19+
"def sink():\n pass\n\ndef caller():\n sink()\n",
20+
)
21+
engine = QueryEngine.from_directory(str(tmp_path))
22+
ancestors = engine.ancestors_of("sink")
23+
names = {a["name"] for a in ancestors}
24+
assert "caller" in names
25+
26+
def test_transitive_ancestor_surfaces(self, tmp_path: Path) -> None:
27+
(tmp_path / "app.py").write_text(
28+
"def sink():\n pass\ndef middle():\n sink()\ndef top():\n middle()\n",
29+
)
30+
engine = QueryEngine.from_directory(str(tmp_path))
31+
names = {a["name"] for a in engine.ancestors_of("sink")}
32+
assert {"middle", "top"}.issubset(names)
33+
34+
def test_unknown_node_returns_empty(self, tmp_path: Path) -> None:
35+
(tmp_path / "app.py").write_text("def only():\n pass\n")
36+
engine = QueryEngine.from_directory(str(tmp_path))
37+
assert engine.ancestors_of("does_not_exist") == []
38+
39+
def test_node_with_no_callers_has_only_module_ancestor(self, tmp_path: Path) -> None:
40+
"""A function with no callers still has the module as an ancestor
41+
via the `contains` edge. Function-level ancestors should be empty.
42+
"""
43+
(tmp_path / "app.py").write_text("def solo():\n pass\n")
44+
engine = QueryEngine.from_directory(str(tmp_path))
45+
ancestor_kinds = {a["kind"] for a in engine.ancestors_of("solo")}
46+
assert "function" not in ancestor_kinds
47+
assert "method" not in ancestor_kinds
48+
49+
50+
class TestReachableFrom:
51+
def test_direct_callee_is_reachable(self, tmp_path: Path) -> None:
52+
(tmp_path / "app.py").write_text(
53+
"def a():\n b()\n\ndef b():\n pass\n",
54+
)
55+
engine = QueryEngine.from_directory(str(tmp_path))
56+
names = {n["name"] for n in engine.reachable_from("a")}
57+
assert "b" in names
58+
59+
def test_transitive_reachability(self, tmp_path: Path) -> None:
60+
(tmp_path / "app.py").write_text(
61+
"def a():\n b()\ndef b():\n c()\ndef c():\n pass\n",
62+
)
63+
engine = QueryEngine.from_directory(str(tmp_path))
64+
names = {n["name"] for n in engine.reachable_from("a")}
65+
assert {"b", "c"}.issubset(names)
66+
67+
68+
class TestEntrypointPathsTo:
69+
def test_path_from_entrypoint_to_sink(self, tmp_path: Path) -> None:
70+
(tmp_path / "app.py").write_text(
71+
"def main():\n validate()\n"
72+
"def validate():\n sensitive()\n"
73+
"def sensitive():\n pass\n",
74+
)
75+
engine = QueryEngine.from_directory(str(tmp_path))
76+
paths = engine.entrypoint_paths_to("sensitive")
77+
assert paths, "Expected at least one entrypoint->sink path"
78+
assert any(p[0] == "app:main" and p[-1] == "app:sensitive" for p in paths), paths
79+
80+
def test_unreachable_sink_returns_empty(self, tmp_path: Path) -> None:
81+
(tmp_path / "app.py").write_text(
82+
"def main():\n pass\n\ndef orphan():\n pass\n",
83+
)
84+
engine = QueryEngine.from_directory(str(tmp_path))
85+
assert engine.entrypoint_paths_to("orphan") == []
86+
87+
88+
class TestNodesWithAnnotation:
89+
def test_manual_annotation_retrievable(self, tmp_path: Path) -> None:
90+
(tmp_path / "app.py").write_text("def risky():\n pass\n")
91+
engine = QueryEngine.from_directory(str(tmp_path))
92+
engine.annotate(
93+
"risky",
94+
AnnotationKind.FINDING,
95+
"manually flagged",
96+
source="test",
97+
)
98+
finds = engine.nodes_with_annotation(AnnotationKind.FINDING)
99+
names = {n["name"] for n in finds}
100+
assert "risky" in names
101+
102+
def test_empty_when_no_annotations(self, tmp_path: Path) -> None:
103+
(tmp_path / "app.py").write_text("def ok():\n pass\n")
104+
engine = QueryEngine.from_directory(str(tmp_path))
105+
assert engine.nodes_with_annotation(AnnotationKind.FINDING) == []
106+
107+
108+
class TestFunctionsThatRaise:
109+
def test_raises_detected_from_python_source(self, tmp_path: Path) -> None:
110+
"""Python parser populates exception_types from `raise` statements."""
111+
(tmp_path / "app.py").write_text(
112+
"def bad():\n raise ValueError('nope')\n\ndef good():\n return 1\n",
113+
)
114+
engine = QueryEngine.from_directory(str(tmp_path))
115+
raisers = engine.functions_that_raise("ValueError")
116+
names = {n["name"] for n in raisers}
117+
assert "bad" in names
118+
assert "good" not in names
119+
120+
def test_unknown_exception_returns_empty(self, tmp_path: Path) -> None:
121+
(tmp_path / "app.py").write_text(
122+
"def bad():\n raise ValueError('nope')\n",
123+
)
124+
engine = QueryEngine.from_directory(str(tmp_path))
125+
assert engine.functions_that_raise("KeyError") == []

0 commit comments

Comments
 (0)