1use std::io::Write;
12use std::path::Path;
13use std::process::{Command, Stdio};
14
15use serde::Deserialize;
16
17use super::{FunctionFacts, LangFindings, Sighting};
18
19const AST_WALKER: &str = r#"
27import ast, json, os, sys
28from urllib.parse import urlsplit
29
30HTTP_LIBS = {"httpx", "requests", "aiohttp", "urllib3"}
31LLM_LIBS = {"openai", "anthropic", "google.genai"}
32OTHER_LIBS = {"psycopg", "boto3"}
33# The agent-framework packs (dx-spec agent-first-class work). pydantic_ai,
34# crewai, langgraph, and openai-agents (import name `agents`) are plain
35# top-level module names, classified exactly like HTTP_LIBS/LLM_LIBS/
36# OTHER_LIBS above. `mcp` is the SDK's own import name (mcp_pack) — a normal
37# adapter lib here, not special-cased. `google.adk` is handled separately via
38# dotted-prefix matching (see `import_entries`): bare `google` is a namespace
39# package shared with unrelated distributions (google-protobuf and friends)
40# and must record nothing.
41AGENT_LIBS = {"pydantic_ai", "crewai", "langgraph", "agents", "mcp"}
42KNOWN = HTTP_LIBS | LLM_LIBS | OTHER_LIBS | AGENT_LIBS | {"google.adk"}
43# Known Python resilience libraries — a `keel doctor` signal that a target may
44# already have its own retry/backoff, separate from KNOWN (which is about
45# libraries Keel *adapts*; these are libraries Keel never adapts, so mixing
46# them into KNOWN would misclassify them as an "invisible" coverage gap).
47RESILIENCE_LIBS = {"tenacity", "backoff", "retrying", "stamina"}
48TIME_LIBS = {"time", "datetime"}
49RANDOM_LIBS = {"random", "uuid", "secrets"}
50UNSAFE_LIBS = {"threading", "multiprocessing", "subprocess", "socket"}
51TRACKED = KNOWN | TIME_LIBS | RANDOM_LIBS | UNSAFE_LIBS | {"os"}
52TIME_NAMES = {"time", "time_ns", "monotonic", "monotonic_ns",
53 "perf_counter", "perf_counter_ns", "gmtime", "localtime"}
54DT_NAMES = {"now", "utcnow", "today"}
55UUID_NAMES = {"uuid1", "uuid3", "uuid4", "uuid5"}
56OS_UNSAFE = {"system", "popen", "fork", "forkpty", "execv", "execve",
57 "execvp", "execvpe", "spawnl", "spawnv", "spawnvp"}
58SKIP = {".keel", ".git", "__pycache__", "node_modules", ".venv", "venv",
59 ".mypy_cache", ".pytest_cache", "dist", "build", "target"}
60# The two `google` submodules Keel adapts. Bare `import google` (the
61# namespace package) records nothing — it also hosts unrelated distributions
62# (google-protobuf and friends) that are none of Keel's business.
63GOOGLE_SUBMODULES = {"adk", "genai"}
64
65
66def top(mod):
67 return mod.split(".", 1)[0] if mod else ""
68
69
70def import_entries(node):
71 """Yield (module_key, bound_name) for each name an Import/ImportFrom node
72 binds. Handles the `google.adk` / `google.genai` dotted-prefix special
73 case: `import google.adk`, `from google.adk import ...`, and
74 `from google import genai, adk` all resolve to `google.<name>`; a bare
75 `google` import (or `from google import <something else>`) yields
76 nothing — the google namespace package alone is not evidence of either
77 library."""
78 if isinstance(node, ast.Import):
79 for alias in node.names:
80 parts = alias.name.split(".")
81 if len(parts) >= 2 and parts[0] == "google" and parts[1] in GOOGLE_SUBMODULES:
82 key = "google." + parts[1]
83 else:
84 key = parts[0]
85 yield key, (alias.asname or alias.name).split(".", 1)[0]
86 elif isinstance(node, ast.ImportFrom):
87 mod = node.module or ""
88 mparts = mod.split(".")
89 if len(mparts) >= 2 and mparts[0] == "google" and mparts[1] in GOOGLE_SUBMODULES:
90 key = "google." + mparts[1]
91 for alias in node.names:
92 yield key, alias.asname or alias.name
93 elif mod == "google":
94 for alias in node.names:
95 if alias.name in GOOGLE_SUBMODULES:
96 yield "google." + alias.name, alias.asname or alias.name
97 else:
98 key = top(mod)
99 for alias in node.names:
100 yield key, alias.asname or alias.name
101
102
103def host(s):
104 if "://" not in s:
105 return None
106 try:
107 parts = urlsplit(s.strip())
108 except ValueError:
109 return None
110 if not parts.scheme or not parts.hostname:
111 return None
112 return parts.hostname
113
114
115def call_root(f):
116 """The Name at the base of a call's attribute chain, or None. Deliberately
117 does NOT see through intermediate calls: in `httpx.get(u).json()` only the
118 inner `httpx.get(u)` has a root, so a chained method on a call result is
119 never double-counted as a second effect."""
120 while isinstance(f, ast.Attribute):
121 f = f.value
122 return f.id if isinstance(f, ast.Name) else None
123
124
125# Call names that construct a handle rather than perform an effect: CapWords
126# constructors (OpenAI(), Client()) plus the well-known factory methods.
127FACTORY_NAMES = {"client", "resource", "session", "connect"}
128
129
130def is_constructor(name):
131 return name is None or name[:1].isupper() or name in FACTORY_NAMES
132
133
134def aliases_of(tree):
135 """Binding name -> tracked module (top-level, or `google.adk`/
136 `google.genai` — see `import_entries`). Also follows one hop of
137 constructor assignment (client = OpenAI() -> client is an openai handle),
138 the dominant SDK-client pattern."""
139 a = {}
140 for node in ast.walk(tree):
141 if isinstance(node, (ast.Import, ast.ImportFrom)):
142 for key, bound in import_entries(node):
143 if key in TRACKED:
144 a[bound] = key
145 for node in ast.walk(tree):
146 if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
147 lib = a.get(call_root(node.value.func))
148 if lib in KNOWN:
149 for tgt in node.targets:
150 if isinstance(tgt, ast.Name):
151 a[tgt.id] = lib
152 return a
153
154
155def url_consts_of(tree):
156 """Module-level NAME = "scheme://host/..." constants -> host, so a URL
157 hoisted to a constant still attributes to the functions that use it."""
158 consts = {}
159 for node in tree.body:
160 if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant)
161 and isinstance(node.value.value, str)):
162 h = host(node.value.value)
163 if h:
164 for tgt in node.targets:
165 if isinstance(tgt, ast.Name):
166 consts[tgt.id] = h
167 return consts
168
169
170def fn_facts(fn, rel, mod, aliases, url_consts):
171 effects = unsafe_idem = t_reads = r_reads = 0
172 targets = set()
173 reasons = []
174 for node in ast.walk(fn):
175 if isinstance(node, ast.Name) and node.id in url_consts:
176 targets.add(url_consts[node.id])
177 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
178 h = host(node.value)
179 if h:
180 targets.add(h)
181 if not isinstance(node, ast.Call):
182 continue
183 lib = aliases.get(call_root(node.func))
184 if lib is None:
185 continue
186 attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
187 name = attr if attr is not None else call_root(node.func)
188 if lib in KNOWN:
189 if is_constructor(name):
190 continue # a handle being built, not an effect performed
191 effects += 1
192 if name in {"post", "patch"}:
193 unsafe_idem += 1
194 if lib in LLM_LIBS:
195 targets.add("llm:" + lib)
196 elif lib == "time" and name in TIME_NAMES:
197 t_reads += 1
198 elif lib == "datetime" and name in DT_NAMES:
199 t_reads += 1
200 elif lib in {"random", "secrets"}:
201 r_reads += 1
202 elif lib == "uuid" and name in UUID_NAMES:
203 r_reads += 1
204 elif lib == "os" and name == "urandom":
205 r_reads += 1
206 elif lib in UNSAFE_LIBS:
207 reasons.append((node.lineno, "%s use at %s:%d" % (lib, rel, node.lineno)))
208 elif lib == "os" and name in OS_UNSAFE:
209 reasons.append((node.lineno, "os.%s at %s:%d" % (name, rel, node.lineno)))
210 return {"effects": effects, "file": rel, "idempotent_unsafe": unsafe_idem,
211 "line": fn.lineno, "module": mod, "name": fn.name,
212 "random_reads": r_reads, "targets": sorted(targets),
213 "time_reads": t_reads,
214 "unsafe_reasons": [t for _, t in sorted(reasons)]}
215
216
217root = sys.argv[1] if len(sys.argv) > 1 else "."
218imports = []
219urls = []
220functions = []
221resilience_libs = set()
222files = 0
223for dirpath, dirnames, filenames in os.walk(root):
224 dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not d.startswith("."))
225 for fn in sorted(filenames):
226 if not fn.endswith(".py"):
227 continue
228 path = os.path.join(dirpath, fn)
229 rel = os.path.relpath(path, root).replace(os.sep, "/")
230 try:
231 with open(path, "r", encoding="utf-8") as fh:
232 tree = ast.parse(fh.read())
233 except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
234 continue
235 files += 1
236 for node in ast.walk(tree):
237 if isinstance(node, (ast.Import, ast.ImportFrom)):
238 for key, _bound in import_entries(node):
239 if key in KNOWN:
240 imports.append({"lib": key, "file": rel, "line": node.lineno})
241 elif key in RESILIENCE_LIBS:
242 resilience_libs.add(key)
243 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
244 h = host(node.value)
245 if h:
246 urls.append({"host": h, "file": rel, "line": node.lineno})
247 mod = rel[:-3].replace("/", ".")
248 if mod.endswith(".__init__"):
249 mod = mod[: -len(".__init__")]
250 aliases = aliases_of(tree)
251 consts = url_consts_of(tree)
252 for node in tree.body:
253 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
254 functions.append(fn_facts(node, rel, mod, aliases, consts))
255
256print(json.dumps({"files_scanned": files, "functions": functions,
257 "imports": imports, "resilience_libs": sorted(resilience_libs),
258 "urls": urls}, sort_keys=True))
259"#;
260
261#[derive(Debug, Deserialize)]
263struct Import {
264 lib: String,
265 file: String,
266 line: u32,
267}
268
269#[derive(Debug, Deserialize)]
271struct Url {
272 host: String,
273 file: String,
274 line: u32,
275}
276
277#[derive(Debug, Deserialize)]
279struct PyFunction {
280 effects: u32,
281 file: String,
282 idempotent_unsafe: u32,
283 line: u32,
284 module: String,
285 name: String,
286 random_reads: u32,
287 targets: Vec<String>,
288 time_reads: u32,
289 unsafe_reasons: Vec<String>,
290}
291
292#[derive(Debug, Deserialize)]
294struct WalkerOutput {
295 files_scanned: usize,
296 #[serde(default)]
297 functions: Vec<PyFunction>,
298 imports: Vec<Import>,
299 #[serde(default)]
300 resilience_libs: Vec<String>,
301 urls: Vec<Url>,
302}
303
304#[derive(Debug, Clone, Default)]
306pub struct PyScan {
307 pub available: bool,
309 pub files_scanned: usize,
311 pub findings: LangFindings,
313 pub functions: Vec<FunctionFacts>,
315}
316
317const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3"];
318
319fn normalize_lib(lib: &str) -> String {
327 match lib {
328 "google.adk" => "google-adk".to_owned(),
329 "google.genai" => "google-genai".to_owned(),
330 "pydantic_ai" => "pydantic-ai".to_owned(),
331 "agents" => "openai-agents".to_owned(),
332 other => other.to_owned(),
333 }
334}
335
336pub fn scan(project: &Path) -> PyScan {
339 let Some(output) = run_walker(project) else {
340 return PyScan::default();
341 };
342 let mut findings = LangFindings::default();
343 for imp in &output.imports {
344 let lib = normalize_lib(&imp.lib);
345 findings.libs.insert(lib.clone());
346 let sighting = Sighting {
347 file: imp.file.clone(),
348 line: imp.line,
349 };
350 match lib.as_str() {
351 "openai" => findings.llm.push(("openai".to_owned(), sighting)),
352 "anthropic" => findings.llm.push(("anthropic".to_owned(), sighting)),
353 "google-genai" => findings.llm.push(("google-genai".to_owned(), sighting)),
354 lib if HTTP_LIBS.contains(&lib) => findings.http_in_use = true,
355 _ => {}
359 }
360 }
361 if !output.urls.is_empty() {
364 findings.http_in_use = true;
365 }
366 for lib in &output.resilience_libs {
367 findings.resilience_libs.insert(lib.clone());
368 }
369 for url in &output.urls {
370 findings.hosts.push((
373 url.host.to_ascii_lowercase(),
374 Sighting {
375 file: url.file.clone(),
376 line: url.line,
377 },
378 ));
379 }
380 let functions = output
381 .functions
382 .into_iter()
383 .map(|f| FunctionFacts {
384 entrypoint: format!("py:{}:{}", f.module, f.name),
385 file: f.file,
386 line: f.line,
387 effects: f.effects,
388 idempotent_unsafe: f.idempotent_unsafe,
389 time_reads: f.time_reads,
390 random_reads: f.random_reads,
391 unsafe_reasons: f.unsafe_reasons,
392 targets: f.targets.into_iter().collect(),
393 })
394 .collect();
395 PyScan {
396 available: true,
397 files_scanned: output.files_scanned,
398 findings,
399 functions,
400 }
401}
402
403fn run_walker(project: &Path) -> Option<WalkerOutput> {
405 let mut child = Command::new("python3")
406 .arg("-")
407 .arg(project)
408 .stdin(Stdio::piped())
409 .stdout(Stdio::piped())
410 .stderr(Stdio::null())
411 .spawn()
412 .ok()?;
413 child.stdin.take()?.write_all(AST_WALKER.as_bytes()).ok()?;
414 let out = child.wait_with_output().ok()?;
415 if !out.status.success() {
416 return None;
417 }
418 serde_json::from_slice(&out.stdout).ok()
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use std::fs;
425 use tempfile::TempDir;
426
427 fn python3_present() -> bool {
428 Command::new("python3")
429 .arg("--version")
430 .stdout(Stdio::null())
431 .stderr(Stdio::null())
432 .status()
433 .is_ok_and(|s| s.success())
434 }
435
436 #[test]
437 fn walks_imports_and_url_literals() {
438 if !python3_present() {
439 eprintln!("skip: python3 not available");
440 return;
441 }
442 let dir = TempDir::new().unwrap();
443 fs::write(
444 dir.path().join("app.py"),
445 "import httpx\nfrom openai import OpenAI\n\nURL = \"https://api.example.com/v1\"\n",
446 )
447 .unwrap();
448 let scan = scan(dir.path());
449 assert!(scan.available);
450 assert_eq!(scan.files_scanned, 1);
451 assert!(scan.findings.http_in_use);
452 assert!(
453 scan.findings
454 .llm
455 .iter()
456 .any(|(p, s)| p == "openai" && s.file == "app.py" && s.line == 2)
457 );
458 assert!(
459 scan.findings
460 .hosts
461 .iter()
462 .any(|(h, s)| h == "api.example.com" && s.line == 4)
463 );
464 }
465
466 #[test]
467 fn agent_pack_imports_and_the_google_dotted_prefix_are_classified() {
468 if !python3_present() {
469 eprintln!("skip: python3 not available");
470 return;
471 }
472 let dir = TempDir::new().unwrap();
473 fs::write(
474 dir.path().join("app.py"),
475 "import google\n\
476 import google.protobuf\n\
477 from google import protobuf\n\
478 import google.adk\n\
479 from google.genai import types\n\
480 from google import genai, adk\n\
481 from pydantic_ai import Agent\n\
482 import crewai\n\
483 from langgraph.graph import StateGraph\n\
484 from agents import Agent as OpenAIAgent\n\
485 import mcp\n\
486 from tenacity import retry\n",
487 )
488 .unwrap();
489 let scan = scan(dir.path());
490 assert!(scan.available);
491 for lib in [
494 "google-adk",
495 "google-genai",
496 "pydantic-ai",
497 "crewai",
498 "langgraph",
499 "openai-agents",
500 "mcp",
501 ] {
502 assert!(scan.findings.libs.contains(lib), "missing lib: {lib}");
503 }
504 assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
506 assert_eq!(
508 scan.findings.resilience_libs,
509 ["tenacity".to_owned()].into_iter().collect()
510 );
511 for leaked in ["google", "google.protobuf", "protobuf"] {
519 assert!(
520 !scan.findings.libs.contains(leaked),
521 "google.protobuf import must not record {leaked}"
522 );
523 }
524 }
525
526 #[test]
527 fn resilience_lib_imports_are_detected_separately_from_known_libs() {
528 if !python3_present() {
529 eprintln!("skip: python3 not available");
530 return;
531 }
532 let dir = TempDir::new().unwrap();
533 fs::write(
534 dir.path().join("app.py"),
535 "import httpx\nfrom tenacity import retry\nimport backoff\n",
536 )
537 .unwrap();
538 let scan = scan(dir.path());
539 assert_eq!(
540 scan.findings.resilience_libs,
541 ["backoff".to_owned(), "tenacity".to_owned()]
542 .into_iter()
543 .collect()
544 );
545 assert!(!scan.findings.libs.contains("tenacity"));
549 assert!(!scan.findings.libs.contains("backoff"));
550 assert!(scan.findings.libs.contains("httpx"));
551 }
552
553 #[test]
554 fn attributes_effects_time_random_to_module_level_functions() {
555 if !python3_present() {
556 eprintln!("skip: python3 not available");
557 return;
558 }
559 let dir = TempDir::new().unwrap();
560 fs::write(
561 dir.path().join("pipeline.py"),
562 r#"import time
563import random
564import httpx
565from openai import OpenAI
566
567API = "https://api.example.com/v1/data"
568client = OpenAI()
569
570
571def main():
572 started = time.time()
573 seed = random.random()
574 data = httpx.get(API).json()
575 httpx.post(API, json=data)
576 client.responses.create(model="gpt-4.1", input="hi")
577 return started, seed
578
579
580def helper():
581 return 41 + 1
582"#,
583 )
584 .unwrap();
585 let s = scan(dir.path());
586 let main = s
587 .functions
588 .iter()
589 .find(|f| f.entrypoint == "py:pipeline:main")
590 .expect("main attributed");
591 assert_eq!(main.effects, 3);
593 assert_eq!(main.idempotent_unsafe, 1, "only the POST");
594 assert_eq!(main.time_reads, 1);
595 assert_eq!(main.random_reads, 1);
596 assert!(main.unsafe_reasons.is_empty());
597 assert!(main.targets.contains("api.example.com"), "URL via constant");
598 assert!(main.targets.contains("llm:openai"), "client = OpenAI() hop");
599 assert_eq!((main.file.as_str(), main.line), ("pipeline.py", 10));
600 let helper = s
601 .functions
602 .iter()
603 .find(|f| f.entrypoint == "py:pipeline:helper")
604 .expect("helper attributed");
605 assert_eq!(helper.effects, 0);
606 }
607
608 #[test]
609 fn threads_and_subprocess_defeat_the_replay_safe_estimate() {
610 if !python3_present() {
611 eprintln!("skip: python3 not available");
612 return;
613 }
614 let dir = TempDir::new().unwrap();
615 fs::write(
616 dir.path().join("jobs.py"),
617 r#"import subprocess
618import threading
619import requests
620
621
622def risky():
623 requests.post("https://api.example.com/v1/x")
624 threading.Thread(target=print).start()
625 subprocess.run(["ls"])
626"#,
627 )
628 .unwrap();
629 let s = scan(dir.path());
630 let f = s
631 .functions
632 .iter()
633 .find(|f| f.entrypoint == "py:jobs:risky")
634 .expect("risky attributed");
635 assert_eq!(f.effects, 1);
636 assert_eq!(
637 f.unsafe_reasons,
638 vec![
639 "threading use at jobs.py:8".to_owned(),
640 "subprocess use at jobs.py:9".to_owned(),
641 ]
642 );
643 }
644
645 #[test]
646 fn syntax_error_file_is_skipped_not_fatal() {
647 if !python3_present() {
648 eprintln!("skip: python3 not available");
649 return;
650 }
651 let dir = TempDir::new().unwrap();
652 fs::write(dir.path().join("broken.py"), "def (:\n").unwrap();
653 fs::write(dir.path().join("ok.py"), "import requests\n").unwrap();
654 let scan = scan(dir.path());
655 assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
656 assert!(scan.findings.http_in_use);
657 }
658}