agentlatch-security 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ """Agent checks that work from source code alone, with or without an agent manifest.
2
+
3
+ - PRM001 / PRM002 on prompts written in Python (SystemMessage, ("system", ...) tuples,
4
+ {"role": "system"} dicts, CrewAI goal/backstory, Agents SDK instructions, *_PROMPT variables)
5
+ and in YAML prompt files such as CrewAI config/agents.yaml. (ASI01 Agent Goal Hijack)
6
+ - AG004: a function exposed to the model as a tool runs shell commands, writes or deletes files,
7
+ sends email, writes to a database, or sends HTTP writes, and no approval gate is visible.
8
+ - AG005: an agent is given an unrestricted built-in tool (shell, Python REPL, file system).
9
+ - AG006 / AG007 (human approval disabled, unbounded agent loops) live in oversight.py.
10
+ (ASI02 Tool Misuse and Exploitation; ASI05/ASI03)
11
+
12
+ All checks are single-file, name-based heuristics over the AST. Nothing is imported or executed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import re
19
+ from collections.abc import Iterator
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ from agent_latch.astutil import walk
25
+ from agent_latch.findings import Finding
26
+ from agent_latch.oversight import scan_oversight
27
+ from agent_latch.prompts import check_prompt_text
28
+ from agent_latch.yamlload import LineDict, line_of, load_yaml
29
+
30
+ # ---------------------------------------------------------------- shared helpers
31
+
32
+
33
+ def _dotted(node: ast.AST | None) -> str | None:
34
+ if isinstance(node, ast.Name):
35
+ return node.id
36
+ if isinstance(node, ast.Attribute):
37
+ base = _dotted(node.value)
38
+ return f"{base}.{node.attr}" if base else node.attr
39
+ if isinstance(node, ast.Call):
40
+ return _dotted(node.func)
41
+ return None
42
+
43
+
44
+ def _last(name: str | None) -> str:
45
+ return name.rsplit(".", 1)[-1] if name else ""
46
+
47
+
48
+ def _keyword(call: ast.Call, name: str) -> ast.expr | None:
49
+ return next((kw.value for kw in call.keywords if kw.arg == name), None)
50
+
51
+
52
+ def _is_true(node: ast.expr | None) -> bool:
53
+ return isinstance(node, ast.Constant) and node.value is True
54
+
55
+
56
+ def _str_constants(node: ast.AST) -> list[str]:
57
+ return [n.value for n in ast.walk(node) if isinstance(n, ast.Constant) and isinstance(n.value, str)]
58
+
59
+
60
+ # ---------------------------------------------------------------- prompts in code (PRM001/PRM002)
61
+
62
+ _SYSTEM_ROLES = {"system", "developer"}
63
+ _USER_ROLES = {"user", "human"}
64
+ _SYSTEM_KEYWORDS = {"system_prompt", "system_message", "system", "instructions", "backstory", "goal"}
65
+ _PROMPT_KEYWORDS = {"description", "expected_output", "template", "user_prompt"}
66
+ _AGENT_FACTORIES = {"create_react_agent", "create_agent"} # prompt= is the system prompt here
67
+ _SYSTEM_VAR = re.compile(r"(?i)system_?(?:prompt|message|instructions?)")
68
+ _PROMPT_VAR = re.compile(r"(?i)prompt")
69
+
70
+
71
+ def _render(node: ast.AST) -> str | None:
72
+ """Prompt text for a string expression; interpolated values become {name} placeholders."""
73
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
74
+ return node.value
75
+ if isinstance(node, ast.JoinedStr):
76
+ parts: list[str] = []
77
+ for value in node.values:
78
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
79
+ parts.append(value.value)
80
+ elif isinstance(value, ast.FormattedValue):
81
+ parts.append("{" + _placeholder(value.value) + "}")
82
+ return "".join(parts)
83
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
84
+ left, right = _render(node.left), _render(node.right)
85
+ if left is None and right is None:
86
+ return None
87
+ return (left if left is not None else "{" + _placeholder(node.left) + "}") + (
88
+ right if right is not None else "{" + _placeholder(node.right) + "}"
89
+ )
90
+ return None
91
+
92
+
93
+ def _placeholder(node: ast.AST) -> str:
94
+ if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant):
95
+ return str(node.slice.value)
96
+ return _last(_dotted(node)) or "expr"
97
+
98
+
99
+ class _PromptCollector:
100
+ def __init__(self, path: str) -> None:
101
+ self.path = path
102
+ self.seen: set[int] = set()
103
+ self.findings: list[Finding] = []
104
+
105
+ def check(self, node: ast.AST | None, label: str, is_system: bool) -> None:
106
+ if node is None or id(node) in self.seen:
107
+ return
108
+ text = _render(node)
109
+ if text is None:
110
+ return
111
+ self.seen.add(id(node))
112
+ self.findings.extend(
113
+ check_prompt_text(
114
+ text, self.path, node.lineno, label, is_system, base_column=node.col_offset + 1
115
+ )
116
+ )
117
+
118
+ def visit(self, tree: ast.Module) -> list[Finding]:
119
+ for node in walk(tree):
120
+ if isinstance(node, ast.Call):
121
+ self._call(node)
122
+ elif isinstance(node, ast.Tuple) and len(node.elts) == 2:
123
+ role = node.elts[0]
124
+ if isinstance(role, ast.Constant) and isinstance(role.value, str):
125
+ if role.value in _SYSTEM_ROLES:
126
+ self.check(node.elts[1], f"('{role.value}', ...) prompt message", True)
127
+ elif role.value in _USER_ROLES:
128
+ self.check(node.elts[1], f"('{role.value}', ...) prompt message", False)
129
+ elif isinstance(node, ast.Dict):
130
+ self._message_dict(node)
131
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
132
+ self._assignment(node)
133
+ return self.findings
134
+
135
+ def _call(self, call: ast.Call) -> None:
136
+ callee = _dotted(call.func) or ""
137
+ name = _last(callee)
138
+ first = call.args[0] if call.args else None
139
+ if name == "SystemMessage":
140
+ self.check(_keyword(call, "content") or first, "System prompt in SystemMessage(...)", True)
141
+ elif name == "HumanMessage":
142
+ self.check(_keyword(call, "content") or first, "Prompt in HumanMessage(...)", False)
143
+ elif name == "from_template":
144
+ is_system = _last(callee.rsplit(".", 1)[0]) == "SystemMessagePromptTemplate"
145
+ self.check(_keyword(call, "template") or first, f"Prompt template in {callee}(...)", is_system)
146
+ if name in _AGENT_FACTORIES:
147
+ self.check(_keyword(call, "prompt"), f"System prompt passed to {name}(prompt=...)", True)
148
+ for keyword in call.keywords:
149
+ if keyword.arg in _SYSTEM_KEYWORDS:
150
+ self.check(keyword.value, f"System-level {keyword.arg}= passed to {name}(...)", True)
151
+ elif keyword.arg in _PROMPT_KEYWORDS:
152
+ self.check(keyword.value, f"{keyword.arg}= passed to {name}(...)", False)
153
+
154
+ def _message_dict(self, node: ast.Dict) -> None:
155
+ entries = {
156
+ key.value: value
157
+ for key, value in zip(node.keys, node.values)
158
+ if isinstance(key, ast.Constant) and isinstance(key.value, str)
159
+ }
160
+ role = entries.get("role")
161
+ if isinstance(role, ast.Constant) and role.value in _SYSTEM_ROLES | _USER_ROLES:
162
+ self.check(
163
+ entries.get("content"),
164
+ f'{{"role": "{role.value}"}} message content',
165
+ role.value in _SYSTEM_ROLES,
166
+ )
167
+
168
+ def _assignment(self, node: ast.Assign | ast.AnnAssign) -> None:
169
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
170
+ names = [_last(_dotted(target)) for target in targets]
171
+ if any(_SYSTEM_VAR.search(name) for name in names):
172
+ self.check(node.value, f"System prompt assigned to {names[0]}", True)
173
+ elif any(_PROMPT_VAR.search(name) for name in names):
174
+ self.check(node.value, f"Prompt assigned to {names[0]}", False)
175
+
176
+
177
+ # ---------------------------------------------------------------- prompts in YAML files
178
+
179
+ _YAML_SYSTEM_KEYS = {"system_prompt", "system_message", "system", "instructions", "backstory", "goal"}
180
+ _YAML_PROMPT_KEYS = {"prompt", "user_prompt", "prompt_template", "template", "description", "expected_output"}
181
+
182
+
183
+ def scan_prompt_yaml(path: str, source: str) -> list[Finding]:
184
+ """PRM001/PRM002 over prompt-like keys in any YAML file (CrewAI agents.yaml, prompt libraries)."""
185
+ try:
186
+ data = load_yaml(source)
187
+ except yaml.YAMLError:
188
+ return []
189
+ findings: list[Finding] = []
190
+ for mapping in _iter_mappings(data):
191
+ role = mapping.get("role")
192
+ content = mapping.get("content")
193
+ if isinstance(role, str) and isinstance(content, str) and role in _SYSTEM_ROLES | _USER_ROLES:
194
+ findings.extend(_yaml_prompt(mapping, "content", path, f"YAML '{role}' message", role in _SYSTEM_ROLES))
195
+ for key, value in mapping.items():
196
+ if not isinstance(key, str) or not isinstance(value, str):
197
+ continue
198
+ if key in _YAML_SYSTEM_KEYS:
199
+ findings.extend(_yaml_prompt(mapping, key, path, f"YAML key '{key}'", True))
200
+ elif key in _YAML_PROMPT_KEYS:
201
+ findings.extend(_yaml_prompt(mapping, key, path, f"YAML key '{key}'", False))
202
+ return findings
203
+
204
+
205
+ def _iter_mappings(data: Any) -> Iterator[LineDict]:
206
+ stack = [data]
207
+ while stack:
208
+ item = stack.pop()
209
+ if isinstance(item, LineDict):
210
+ yield item
211
+ stack.extend(item.values())
212
+ elif isinstance(item, list):
213
+ stack.extend(item)
214
+
215
+
216
+ def _yaml_prompt(mapping: LineDict, key: str, path: str, label: str, is_system: bool) -> list[Finding]:
217
+ text = mapping[key]
218
+ # Literal block scalars (|, >) start on the line after their key, as in manifest.py.
219
+ start = line_of(mapping, key) + 1 if "\n" in text else line_of(mapping, key)
220
+ return check_prompt_text(text, path, start, label, is_system)
221
+
222
+
223
+ # ---------------------------------------------------------------- AG004: risky tools without approval
224
+
225
+ _TOOL_DECORATORS = {"tool", "function_tool", "kernel_function"}
226
+ _TOOL_BASE_CLASSES = {"BaseTool"}
227
+ _TOOL_RUN_METHODS = {"_run", "_arun", "run", "arun"}
228
+ _TOOL_WRAPPERS = {"Tool", "StructuredTool", "FunctionTool", "from_function", "from_defaults"}
229
+ _TOOL_LIST_CALLS = {
230
+ "create_react_agent",
231
+ "create_agent",
232
+ "create_tool_calling_agent",
233
+ "create_openai_tools_agent",
234
+ "ToolNode",
235
+ "bind_tools",
236
+ }
237
+ _APPROVAL_KWARGS = {"needs_approval", "requires_approval", "require_approval", "require_confirmation", "human_in_the_loop"}
238
+ _FILE_APPROVAL_KWARGS = {"interrupt_before", "interrupt_after", "interrupt_on"}
239
+ _FILE_APPROVAL_NAMES = {"HumanApprovalCallbackHandler", "HumanInTheLoopMiddleware"}
240
+ _APPROVAL_CALL = re.compile(r"(?i)approv|confirm")
241
+
242
+ _CODE_EXEC_CALLS = {"eval", "exec", "os.system", "os.popen", "pty.spawn", "asyncio.create_subprocess_shell", "asyncio.create_subprocess_exec"}
243
+ _FS_CALLS = {"os.remove", "os.unlink", "os.rmdir", "os.removedirs", "os.rename", "os.replace", "shutil.rmtree", "shutil.move"}
244
+ _FS_METHODS = {"unlink", "rmdir", "write_text", "write_bytes"}
245
+ _HTTP_WRITE_CALLS = {f"{lib}.{verb}" for lib in ("requests", "httpx") for verb in ("post", "put", "patch", "delete")}
246
+ _EMAIL_CALLS = {"smtplib.SMTP", "smtplib.SMTP_SSL"}
247
+ _EMAIL_METHODS = {"sendmail", "send_message"}
248
+ _SQL_WRITE = re.compile(r"^\s*(?:INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|REPLACE)\b", re.IGNORECASE)
249
+
250
+ _FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
251
+
252
+
253
+ def _risky_operation(call: ast.Call) -> tuple[str, str] | None:
254
+ """(kind, evidence) for a call that performs a high-impact action, else None."""
255
+ callee = _dotted(call.func) or ""
256
+ method = call.func.attr if isinstance(call.func, ast.Attribute) else ""
257
+ if callee in _CODE_EXEC_CALLS or callee.startswith(("subprocess.", "os.exec", "os.spawn")):
258
+ return "code_execution", f"{callee}()"
259
+ if callee in _FS_CALLS or method in _FS_METHODS:
260
+ return "filesystem", f"{callee or method}()"
261
+ if callee == "open":
262
+ mode = _keyword(call, "mode") or (call.args[1] if len(call.args) > 1 else None)
263
+ if isinstance(mode, ast.Constant) and isinstance(mode.value, str) and set(mode.value) & set("wax+"):
264
+ return "filesystem", f"open(..., {mode.value!r})"
265
+ if callee in _HTTP_WRITE_CALLS:
266
+ return "http_write", f"{callee}()"
267
+ if callee in _EMAIL_CALLS or method in _EMAIL_METHODS:
268
+ return "email", f"{callee or method}()"
269
+ if method in {"execute", "executemany", "executescript"} and call.args:
270
+ sql = _render(call.args[0])
271
+ if sql and _SQL_WRITE.match(sql):
272
+ return "database_write", f"{method}({sql.split()[0].upper()} ...)"
273
+ return None
274
+
275
+
276
+ _KIND_TEXT = {
277
+ "code_execution": "run shell commands or code",
278
+ "filesystem": "write or delete files",
279
+ "http_write": "send HTTP write requests",
280
+ "email": "send email",
281
+ "database_write": "modify a database",
282
+ }
283
+
284
+
285
+ def _approval_call(call: ast.Call) -> bool:
286
+ callee = _dotted(call.func) or ""
287
+ return _last(callee) in {"interrupt", "input"} or bool(_APPROVAL_CALL.search(_last(callee)))
288
+
289
+
290
+ def _decorator_tool(decorator: ast.expr) -> tuple[bool, bool]:
291
+ """(is_tool, declares_approval) for one decorator."""
292
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
293
+ if _last(_dotted(target)) not in _TOOL_DECORATORS:
294
+ return False, False
295
+ approved = isinstance(decorator, ast.Call) and any(
296
+ kw.arg in _APPROVAL_KWARGS and not (isinstance(kw.value, ast.Constant) and not kw.value.value)
297
+ for kw in decorator.keywords
298
+ )
299
+ return True, approved
300
+
301
+
302
+ class _ToolAnalysis:
303
+ def __init__(self, tree: ast.Module, path: str) -> None:
304
+ self.tree = tree
305
+ self.path = path
306
+ self.functions: dict[str, _FunctionNode] = {
307
+ node.name: node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
308
+ }
309
+
310
+ def file_has_approval_gate(self) -> bool:
311
+ for node in walk(self.tree):
312
+ if (
313
+ isinstance(node, ast.keyword)
314
+ and node.arg in _FILE_APPROVAL_KWARGS
315
+ and not (isinstance(node.value, ast.Constant) and not node.value.value)
316
+ ):
317
+ return True
318
+ if isinstance(node, (ast.Name, ast.Attribute)) and _last(_dotted(node)) in _FILE_APPROVAL_NAMES:
319
+ return True
320
+ if isinstance(node, ast.alias) and _last(node.name) in _FILE_APPROVAL_NAMES:
321
+ return True
322
+ return False
323
+
324
+ def tools(self) -> list[tuple[str, ast.AST, list[_FunctionNode], bool]]:
325
+ """(tool name, report node, bodies to inspect, declares approval)."""
326
+ found: dict[str, tuple[str, ast.AST, list[_FunctionNode], bool]] = {}
327
+ for node in walk(self.tree):
328
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
329
+ flags = [_decorator_tool(decorator) for decorator in node.decorator_list]
330
+ if any(is_tool for is_tool, _ in flags):
331
+ found[f"def:{id(node)}"] = (node.name, node, [node], any(ok for _, ok in flags))
332
+ elif isinstance(node, ast.ClassDef) and any(
333
+ _last(_dotted(base)) in _TOOL_BASE_CLASSES for base in node.bases
334
+ ):
335
+ bodies = [
336
+ item
337
+ for item in node.body
338
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name in _TOOL_RUN_METHODS
339
+ ]
340
+ if bodies:
341
+ found[f"class:{id(node)}"] = (node.name, node, bodies, False)
342
+ for name in self._referenced_tool_functions():
343
+ function = self.functions.get(name)
344
+ if function is not None and f"def:{id(function)}" not in found:
345
+ found[f"def:{id(function)}"] = (name, function, [function], False)
346
+ return list(found.values())
347
+
348
+ def _referenced_tool_functions(self) -> set[str]:
349
+ names: set[str] = set()
350
+ for node in walk(self.tree):
351
+ if not isinstance(node, ast.Call):
352
+ continue
353
+ callee = _last(_dotted(node.func))
354
+ lists = [kw.value for kw in node.keywords if kw.arg == "tools"]
355
+ if callee in _TOOL_LIST_CALLS:
356
+ lists.extend(arg for arg in node.args if isinstance(arg, (ast.List, ast.Tuple)))
357
+ for value in lists:
358
+ if isinstance(value, (ast.List, ast.Tuple)):
359
+ names.update(elt.id for elt in value.elts if isinstance(elt, ast.Name))
360
+ if callee in _TOOL_WRAPPERS:
361
+ candidates = [kw.value for kw in node.keywords if kw.arg in {"func", "fn", "coroutine"}]
362
+ if callee in {"from_function", "from_defaults"} and node.args:
363
+ candidates.append(node.args[0])
364
+ names.update(c.id for c in candidates if isinstance(c, ast.Name))
365
+ return names
366
+
367
+ def inspect(self, bodies: list[_FunctionNode]) -> tuple[list[tuple[str, str]], bool]:
368
+ """Risky operations reachable from the tool bodies (one level of same-file helpers)."""
369
+ operations: list[tuple[str, str]] = []
370
+ approved = False
371
+ visited: set[int] = set()
372
+ queue: list[tuple[_FunctionNode, int]] = [(body, 0) for body in bodies]
373
+ while queue:
374
+ function, depth = queue.pop()
375
+ if id(function) in visited:
376
+ continue
377
+ visited.add(id(function))
378
+ for node in walk(function):
379
+ if not isinstance(node, ast.Call):
380
+ continue
381
+ if _approval_call(node):
382
+ approved = True
383
+ operation = _risky_operation(node)
384
+ if operation and operation not in operations:
385
+ operations.append(operation)
386
+ helper = self.functions.get(node.func.id) if isinstance(node.func, ast.Name) else None
387
+ if helper is not None and depth < 1:
388
+ queue.append((helper, depth + 1))
389
+ return operations, approved
390
+
391
+ def findings(self) -> list[Finding]:
392
+ if self.file_has_approval_gate():
393
+ return []
394
+ results: list[Finding] = []
395
+ for name, node, bodies, declared in self.tools():
396
+ operations, approved = self.inspect(bodies)
397
+ if declared or approved or not operations:
398
+ continue
399
+ kinds = list(dict.fromkeys(kind for kind, _ in operations))
400
+ executes_code = "code_execution" in kinds
401
+ results.append(
402
+ Finding(
403
+ rule_id="AGENTLATCH-AG004",
404
+ title="High-risk agent tool without human approval",
405
+ severity="high" if executes_code else "medium",
406
+ message=(
407
+ f"Tool '{name}' lets the model {', '.join(_KIND_TEXT[kind] for kind in kinds)}, "
408
+ "and no approval gate was found in this file (LangGraph interrupt() or interrupt_before, "
409
+ "HumanApprovalCallbackHandler, HumanInTheLoopMiddleware, needs_approval=True). "
410
+ "Require human confirmation for these actions or narrow what the tool can do."
411
+ ),
412
+ path=self.path,
413
+ line=node.lineno,
414
+ column=node.col_offset + 1,
415
+ # ASI09: the PDF lists missing confirmation for sensitive actions as an example.
416
+ owasp=("ASI02", "ASI05", "ASI09") if executes_code else ("ASI02", "ASI03", "ASI09"),
417
+ confidence="medium",
418
+ evidence=f"tool '{name}' calls {', '.join(ev for _, ev in operations[:3])}; no approval gate",
419
+ )
420
+ )
421
+ return results
422
+
423
+
424
+ # ---------------------------------------------------------------- AG005: unrestricted built-in tools
425
+
426
+ _SHELL_TOOLS = {"ShellTool", "BashProcess", "TerminalTool"}
427
+ _PYTHON_TOOLS = {"PythonREPLTool", "PythonAstREPLTool", "PythonREPL"}
428
+ _DANGEROUS_LOAD_TOOLS = {"terminal", "shell", "python_repl"}
429
+
430
+
431
+ def _builtin_tool_findings(tree: ast.Module, path: str) -> list[Finding]:
432
+ results: list[Finding] = []
433
+
434
+ def add(node: ast.Call, severity: str, owasp: tuple[str, ...], evidence: str, why: str) -> None:
435
+ results.append(
436
+ Finding(
437
+ rule_id="AGENTLATCH-AG005",
438
+ title="Agent given an unrestricted built-in tool",
439
+ severity=severity,
440
+ message=f"{why} Restrict the tool to the operations the agent needs, sandbox it, or require approval.",
441
+ path=path,
442
+ line=node.lineno,
443
+ column=node.col_offset + 1,
444
+ owasp=owasp,
445
+ confidence="high",
446
+ evidence=evidence,
447
+ )
448
+ )
449
+
450
+ for node in walk(tree):
451
+ if not isinstance(node, ast.Call):
452
+ continue
453
+ name = _last(_dotted(node.func))
454
+ if name in _SHELL_TOOLS:
455
+ add(node, "high", ("ASI02", "ASI05"), f"{name}(...)", f"{name} lets the model run arbitrary shell commands on the host.")
456
+ elif name in _PYTHON_TOOLS:
457
+ add(node, "high", ("ASI02", "ASI05"), f"{name}(...)", f"{name} lets the model run arbitrary Python on the host.")
458
+ elif name == "load_tools" and node.args:
459
+ dangerous = sorted(set(_str_constants(node.args[0])) & _DANGEROUS_LOAD_TOOLS)
460
+ if dangerous:
461
+ add(node, "high", ("ASI02", "ASI05"), f"load_tools([{', '.join(map(repr, dangerous))}])",
462
+ "load_tools() loads a tool that runs shell commands or Python on the host.")
463
+ elif name == "CodeInterpreterTool" and _is_true(_keyword(node, "unsafe_mode")):
464
+ add(node, "high", ("ASI02", "ASI05"), "CodeInterpreterTool(unsafe_mode=True)",
465
+ "CodeInterpreterTool with unsafe_mode=True runs model-written code directly on the host.")
466
+ elif name == "FileManagementToolkit":
467
+ missing = [kw for kw in ("root_dir", "selected_tools") if _keyword(node, kw) is None]
468
+ if missing:
469
+ add(node, "medium", ("ASI02", "ASI03"), f"FileManagementToolkit(...) without {' or '.join(missing)}",
470
+ "FileManagementToolkit gives the model read, write, move, and delete access to files.")
471
+ elif _is_true(_keyword(node, "allow_dangerous_requests")):
472
+ add(node, "medium", ("ASI02", "ASI03"), f"{name}(allow_dangerous_requests=True)",
473
+ "allow_dangerous_requests=True lets the model send arbitrary HTTP requests, including to internal services.")
474
+ return results
475
+
476
+
477
+ # ---------------------------------------------------------------- entry point
478
+
479
+
480
+ def scan_agent_code(tree: ast.Module, path: str) -> list[Finding]:
481
+ """Manifest-independent agent checks for one parsed Python file."""
482
+ return [
483
+ *_PromptCollector(path).visit(tree),
484
+ *_ToolAnalysis(tree, path).findings(),
485
+ *_builtin_tool_findings(tree, path),
486
+ *scan_oversight(tree, path),
487
+ ]
agent_latch/astutil.py ADDED
@@ -0,0 +1,20 @@
1
+ """Cached AST traversal shared by the rule modules.
2
+
3
+ Each rule walks the same parsed file several times. Caching the node list on the node itself
4
+ makes repeat walks cheap. Scanned trees are never modified, so the cache cannot go stale.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+
11
+ _CACHE = "_agent_latch_nodes"
12
+
13
+
14
+ def walk(node: ast.AST) -> list[ast.AST]:
15
+ """Same nodes, in the same order, as ast.walk(node)."""
16
+ cached = getattr(node, _CACHE, None)
17
+ if cached is None:
18
+ cached = list(ast.walk(node))
19
+ setattr(node, _CACHE, cached)
20
+ return cached