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.
- agent_latch/__init__.py +3 -0
- agent_latch/action_summary.py +211 -0
- agent_latch/agent_code.py +487 -0
- agent_latch/astutil.py +20 -0
- agent_latch/cli.py +500 -0
- agent_latch/findings.py +65 -0
- agent_latch/gates/__init__.py +17 -0
- agent_latch/gates/alr04.py +816 -0
- agent_latch/manifest.py +256 -0
- agent_latch/oversight.py +225 -0
- agent_latch/owasp.py +105 -0
- agent_latch/prompts.py +90 -0
- agent_latch/report.py +241 -0
- agent_latch/rules.py +495 -0
- agent_latch/runtime/__init__.py +34 -0
- agent_latch/runtime/cli.py +314 -0
- agent_latch/runtime/demo.py +78 -0
- agent_latch/runtime/demo_server.py +54 -0
- agent_latch/runtime/events.py +91 -0
- agent_latch/runtime/importer.py +543 -0
- agent_latch/runtime/middleware.py +201 -0
- agent_latch/runtime/normalizer.py +106 -0
- agent_latch/runtime/payloads.py +190 -0
- agent_latch/runtime/proxy.py +44 -0
- agent_latch/runtime/redact.py +100 -0
- agent_latch/runtime/results.py +274 -0
- agent_latch/runtime/sqlite_store.py +230 -0
- agent_latch/runtime/trace.py +199 -0
- agent_latch/suppress.py +169 -0
- agent_latch/taint.py +264 -0
- agent_latch/yamlload.py +43 -0
- agentlatch_security-0.4.0.dist-info/METADATA +391 -0
- agentlatch_security-0.4.0.dist-info/RECORD +37 -0
- agentlatch_security-0.4.0.dist-info/WHEEL +5 -0
- agentlatch_security-0.4.0.dist-info/entry_points.txt +3 -0
- agentlatch_security-0.4.0.dist-info/licenses/LICENSE +21 -0
- agentlatch_security-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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
|