agentsentinel-cli 0.3.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.
- agentsentinel_cli/__init__.py +3 -0
- agentsentinel_cli/cli.py +338 -0
- agentsentinel_cli/discover.py +691 -0
- agentsentinel_cli/discover_report.py +206 -0
- agentsentinel_cli/frameworks.py +144 -0
- agentsentinel_cli/mcp_client.py +241 -0
- agentsentinel_cli/mcp_report.py +186 -0
- agentsentinel_cli/mcp_rules.py +231 -0
- agentsentinel_cli/report.py +191 -0
- agentsentinel_cli/rules.py +239 -0
- agentsentinel_cli/scanner.py +314 -0
- agentsentinel_cli-0.3.0.dist-info/METADATA +187 -0
- agentsentinel_cli-0.3.0.dist-info/RECORD +15 -0
- agentsentinel_cli-0.3.0.dist-info/WHEEL +4 -0
- agentsentinel_cli-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Standalone posture rules — no database required, works purely on static analysis."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from agentsentinel_cli.scanner import AgentInfo, ToolInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass
|
|
8
|
+
class Finding:
|
|
9
|
+
severity: str # CRITICAL | HIGH | MEDIUM | LOW
|
|
10
|
+
rule_id: str
|
|
11
|
+
message: str
|
|
12
|
+
detail: str = ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_INTERNAL_READ_KW = {"db", "database", "crm", "file", "filesystem", "s3_read", "storage_read", "read_file"}
|
|
16
|
+
_EXTERNAL_WRITE_KW = {"email", "smtp", "webhook", "http_post", "http_external", "s3_write", "send", "slack"}
|
|
17
|
+
_READ_PURPOSE_WORDS = {"read", "search", "query", "view", "lookup", "fetch", "get", "retrieve"}
|
|
18
|
+
_CODE_EXEC_CATEGORIES = {"code_execution"}
|
|
19
|
+
_SECRETS_CATEGORIES = {"secrets"}
|
|
20
|
+
_ADMIN_CATEGORIES = {"admin"}
|
|
21
|
+
_INFRA_CATEGORIES = {"infrastructure"}
|
|
22
|
+
_WEB_CATEGORIES = {"web"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rule_exfiltration_path(agent: AgentInfo) -> Finding | None:
|
|
26
|
+
tool_names = {t.name.lower() for t in agent.tools}
|
|
27
|
+
internal_hits = [n for n in tool_names if any(kw in n for kw in _INTERNAL_READ_KW)]
|
|
28
|
+
external_hits = [n for n in tool_names if any(kw in n for kw in _EXTERNAL_WRITE_KW)]
|
|
29
|
+
if internal_hits and external_hits:
|
|
30
|
+
return Finding(
|
|
31
|
+
severity="CRITICAL",
|
|
32
|
+
rule_id="EXFILTRATION_PATH",
|
|
33
|
+
message="Agent holds both internal-read and external-write grants.",
|
|
34
|
+
detail=f"Internal-read: {', '.join(internal_hits)} | External-write: {', '.join(external_hits)}",
|
|
35
|
+
)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rule_code_execution_grant(agent: AgentInfo) -> Finding | None:
|
|
40
|
+
"""CRITICAL: agent holds code execution tools — arbitrary code paths are high-risk."""
|
|
41
|
+
exec_tools = [t.name for t in agent.tools if t.category in _CODE_EXEC_CATEGORIES]
|
|
42
|
+
if exec_tools:
|
|
43
|
+
return Finding(
|
|
44
|
+
severity="CRITICAL",
|
|
45
|
+
rule_id="CODE_EXECUTION_GRANT",
|
|
46
|
+
message="Agent holds code-execution grants. Arbitrary code execution enables full host compromise.",
|
|
47
|
+
detail=f"Execution tools: {', '.join(exec_tools)}",
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rule_hardcoded_credentials(agent: AgentInfo) -> Finding | None:
|
|
53
|
+
"""CRITICAL: hardcoded API keys or secrets detected in source code."""
|
|
54
|
+
if agent.hardcoded_creds:
|
|
55
|
+
return Finding(
|
|
56
|
+
severity="CRITICAL",
|
|
57
|
+
rule_id="HARDCODED_CREDENTIALS",
|
|
58
|
+
message="Hardcoded credentials detected. Rotate immediately and move to environment variables.",
|
|
59
|
+
detail="; ".join(agent.hardcoded_creds),
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _rule_secrets_access_grant(agent: AgentInfo) -> Finding | None:
|
|
65
|
+
"""HIGH: agent has tools that read secrets, vaults, or credentials at runtime."""
|
|
66
|
+
secrets_tools = [t.name for t in agent.tools if t.category in _SECRETS_CATEGORIES]
|
|
67
|
+
if secrets_tools:
|
|
68
|
+
return Finding(
|
|
69
|
+
severity="HIGH",
|
|
70
|
+
rule_id="SECRETS_ACCESS_GRANT",
|
|
71
|
+
message="Agent holds secrets-access grants. Verify this agent needs runtime access to credentials.",
|
|
72
|
+
detail=f"Secrets tools: {', '.join(secrets_tools)}",
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _rule_prompt_injection_vector(agent: AgentInfo) -> Finding | None:
|
|
78
|
+
"""HIGH: agent reads from untrusted web sources AND holds write grants — injection-to-write path."""
|
|
79
|
+
has_web_read = any(t.category in _WEB_CATEGORIES for t in agent.tools)
|
|
80
|
+
write_tools = [t.name for t in agent.tools if t.scope == "write"]
|
|
81
|
+
if has_web_read and write_tools:
|
|
82
|
+
return Finding(
|
|
83
|
+
severity="HIGH",
|
|
84
|
+
rule_id="PROMPT_INJECTION_VECTOR",
|
|
85
|
+
message=(
|
|
86
|
+
"Agent reads from web (untrusted input) and holds write grants — "
|
|
87
|
+
"prompt injection could redirect write operations."
|
|
88
|
+
),
|
|
89
|
+
detail=f"Write grants: {', '.join(write_tools)}",
|
|
90
|
+
)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rule_lateral_movement_path(agent: AgentInfo) -> Finding | None:
|
|
95
|
+
"""HIGH: agent combines admin/IAM grants with network or infrastructure tools."""
|
|
96
|
+
admin_tools = [t.name for t in agent.tools if t.category in _ADMIN_CATEGORIES]
|
|
97
|
+
infra_tools = [t.name for t in agent.tools if t.category in _INFRA_CATEGORIES]
|
|
98
|
+
if admin_tools and infra_tools:
|
|
99
|
+
return Finding(
|
|
100
|
+
severity="HIGH",
|
|
101
|
+
rule_id="LATERAL_MOVEMENT_PATH",
|
|
102
|
+
message=(
|
|
103
|
+
"Agent holds admin/IAM grants alongside infrastructure grants — "
|
|
104
|
+
"potential lateral movement via privilege escalation."
|
|
105
|
+
),
|
|
106
|
+
detail=f"Admin: {', '.join(admin_tools)} | Infra: {', '.join(infra_tools)}",
|
|
107
|
+
)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _rule_unbounded_file_access(agent: AgentInfo) -> Finding | None:
|
|
112
|
+
"""HIGH: agent holds filesystem write grants with no description restricting scope."""
|
|
113
|
+
fs_write = [
|
|
114
|
+
t.name for t in agent.tools
|
|
115
|
+
if t.category == "filesystem" and t.scope == "write"
|
|
116
|
+
]
|
|
117
|
+
if fs_write and not agent.description:
|
|
118
|
+
return Finding(
|
|
119
|
+
severity="HIGH",
|
|
120
|
+
rule_id="UNBOUNDED_FILE_ACCESS",
|
|
121
|
+
message=(
|
|
122
|
+
"Agent holds filesystem write grants with no description. "
|
|
123
|
+
"Without scoped intent, write access is effectively unbounded."
|
|
124
|
+
),
|
|
125
|
+
detail=f"Filesystem write tools: {', '.join(fs_write)}",
|
|
126
|
+
)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _rule_privilege_excess(agent: AgentInfo) -> Finding | None:
|
|
131
|
+
if not agent.description:
|
|
132
|
+
return None
|
|
133
|
+
desc = agent.description.lower()
|
|
134
|
+
if not any(w in desc for w in _READ_PURPOSE_WORDS):
|
|
135
|
+
return None
|
|
136
|
+
elevated = [t.name for t in agent.tools if t.scope in ("write",) or t.is_dangerous]
|
|
137
|
+
if elevated:
|
|
138
|
+
return Finding(
|
|
139
|
+
severity="HIGH",
|
|
140
|
+
rule_id="PRIVILEGE_EXCESS",
|
|
141
|
+
message="Agent description implies read-only purpose but holds write/dangerous grants.",
|
|
142
|
+
detail=f"Elevated grants: {', '.join(elevated)}",
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _rule_dangerous_grants(agent: AgentInfo) -> Finding | None:
|
|
148
|
+
dangerous = [t.name for t in agent.tools if t.is_dangerous]
|
|
149
|
+
if dangerous:
|
|
150
|
+
return Finding(
|
|
151
|
+
severity="HIGH",
|
|
152
|
+
rule_id="DANGEROUS_GRANTS",
|
|
153
|
+
message="Agent holds dangerous tool grants. Verify intent and add rate limits.",
|
|
154
|
+
detail=f"Dangerous tools: {', '.join(dangerous)}",
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _rule_tool_sprawl(agent: AgentInfo) -> Finding | None:
|
|
160
|
+
"""MEDIUM: agent holds too many tools across too many categories — blast radius scales with sprawl."""
|
|
161
|
+
categories = {t.category for t in agent.tools if t.category != "other"}
|
|
162
|
+
if len(agent.tools) > 10 or len(categories) >= 5:
|
|
163
|
+
return Finding(
|
|
164
|
+
severity="MEDIUM",
|
|
165
|
+
rule_id="TOOL_SPRAWL",
|
|
166
|
+
message=(
|
|
167
|
+
f"Agent holds {len(agent.tools)} tools across {len(categories)} categories. "
|
|
168
|
+
"Reduce grants to the minimum required for each task."
|
|
169
|
+
),
|
|
170
|
+
detail=f"Categories present: {', '.join(sorted(categories))}",
|
|
171
|
+
)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _rule_write_without_description(agent: AgentInfo) -> Finding | None:
|
|
176
|
+
write_tools = [t.name for t in agent.tools if t.scope == "write"]
|
|
177
|
+
if write_tools and not agent.description:
|
|
178
|
+
return Finding(
|
|
179
|
+
severity="MEDIUM",
|
|
180
|
+
rule_id="UNDESCRIBED_WRITE_AGENT",
|
|
181
|
+
message="Agent has write-scope grants but no description.",
|
|
182
|
+
detail=(
|
|
183
|
+
f"Write tools: {', '.join(write_tools)}. "
|
|
184
|
+
"Add a description so posture rules can assess intent."
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _rule_missing_rate_limit(agent: AgentInfo) -> Finding | None:
|
|
191
|
+
"""Flag dangerous tools — rate limits aren't visible in static analysis."""
|
|
192
|
+
dangerous = [t.name for t in agent.tools if t.is_dangerous]
|
|
193
|
+
if dangerous:
|
|
194
|
+
return Finding(
|
|
195
|
+
severity="LOW",
|
|
196
|
+
rule_id="MISSING_RATE_LIMIT",
|
|
197
|
+
message="Dangerous grants detected. Ensure rate limits are configured at runtime.",
|
|
198
|
+
detail=f"Tools to check: {', '.join(dangerous)}",
|
|
199
|
+
)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_ALL_RULES = [
|
|
204
|
+
# CRITICAL
|
|
205
|
+
_rule_exfiltration_path,
|
|
206
|
+
_rule_code_execution_grant,
|
|
207
|
+
_rule_hardcoded_credentials,
|
|
208
|
+
# HIGH
|
|
209
|
+
_rule_secrets_access_grant,
|
|
210
|
+
_rule_prompt_injection_vector,
|
|
211
|
+
_rule_lateral_movement_path,
|
|
212
|
+
_rule_unbounded_file_access,
|
|
213
|
+
_rule_privilege_excess,
|
|
214
|
+
_rule_dangerous_grants,
|
|
215
|
+
# MEDIUM
|
|
216
|
+
_rule_tool_sprawl,
|
|
217
|
+
_rule_write_without_description,
|
|
218
|
+
# LOW
|
|
219
|
+
_rule_missing_rate_limit,
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
_SEVERITY_WEIGHT = {"CRITICAL": 40, "HIGH": 20, "MEDIUM": 10, "LOW": 5}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_rules(agent: AgentInfo) -> list[Finding]:
|
|
226
|
+
findings = []
|
|
227
|
+
seen_rules: set[str] = set()
|
|
228
|
+
for rule_fn in _ALL_RULES:
|
|
229
|
+
f = rule_fn(agent)
|
|
230
|
+
if f and f.rule_id not in seen_rules:
|
|
231
|
+
findings.append(f)
|
|
232
|
+
seen_rules.add(f.rule_id)
|
|
233
|
+
return findings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def posture_score(findings: list[Finding]) -> int:
|
|
237
|
+
"""Calculate posture score (0-100) from findings, same formula as the platform."""
|
|
238
|
+
deductions = sum(_SEVERITY_WEIGHT.get(f.severity, 0) for f in findings)
|
|
239
|
+
return max(0, 100 - deductions)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Static analysis engine — extracts tool definitions from Python agent files using AST."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import dataclasses
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Data models
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass
|
|
13
|
+
class ToolInfo:
|
|
14
|
+
name: str
|
|
15
|
+
scope: str # "read" | "write"
|
|
16
|
+
is_dangerous: bool
|
|
17
|
+
source: str # how it was detected
|
|
18
|
+
docstring: str = ""
|
|
19
|
+
category: str = "other"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclasses.dataclass
|
|
23
|
+
class AgentInfo:
|
|
24
|
+
file: Path
|
|
25
|
+
tools: list[ToolInfo]
|
|
26
|
+
description: str = ""
|
|
27
|
+
model: str = ""
|
|
28
|
+
hardcoded_creds: list[str] = dataclasses.field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Classification helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_WRITE_PATTERNS = (
|
|
36
|
+
"write", "edit", "create", "delete", "remove", "move", "rename",
|
|
37
|
+
"execute", "run", "exec", "patch", "update", "insert", "drop",
|
|
38
|
+
"truncate", "send", "post", "put", "upload", "deploy", "reset", "kill",
|
|
39
|
+
)
|
|
40
|
+
_DANGEROUS_PATTERNS = (
|
|
41
|
+
"delete", "remove", "drop", "truncate", "execute", "run", "exec",
|
|
42
|
+
"send", "deploy", "reset", "kill",
|
|
43
|
+
)
|
|
44
|
+
_KNOWN_MODELS = (
|
|
45
|
+
"gpt-", "claude-", "gemini-", "mistral", "llama", "mixtral",
|
|
46
|
+
"command", "titan", "nova",
|
|
47
|
+
)
|
|
48
|
+
_TOOL_CATEGORIES = {
|
|
49
|
+
"database": ("database", "db", "sql", "query", "postgres", "mysql", "mongo"),
|
|
50
|
+
"filesystem": ("file", "directory", "disk", "path", "read_file", "write_file"),
|
|
51
|
+
"web": ("http", "fetch", "url", "web", "scrape", "browse", "request"),
|
|
52
|
+
"communication": ("email", "smtp", "slack", "webhook", "notify", "send_message"),
|
|
53
|
+
"code_execution":("exec", "bash", "shell", "run_code", "eval", "terminal", "subprocess", "python_repl"),
|
|
54
|
+
"secrets": ("secret", "credential", "vault", "token", "password", "api_key", "read_env"),
|
|
55
|
+
"admin": ("admin", "iam", "role", "permission", "policy", "sudo", "privilege"),
|
|
56
|
+
"crm": ("crm", "customer", "account", "contact", "salesforce", "hubspot"),
|
|
57
|
+
"analytics": ("analytics", "metric", "report", "dashboard", "bi", "insight"),
|
|
58
|
+
"infrastructure":("deploy", "container", "k8s", "kubernetes", "aws", "gcp", "azure", "terraform"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Credential detection
|
|
62
|
+
_CRED_VARNAMES = frozenset({
|
|
63
|
+
"api_key", "apikey", "api_token", "token", "secret", "password",
|
|
64
|
+
"passwd", "credential", "auth_token", "access_token", "secret_key",
|
|
65
|
+
"private_key", "bearer_token", "auth_key",
|
|
66
|
+
})
|
|
67
|
+
_CRED_PREFIXES = (
|
|
68
|
+
"sk-ant-", "sk-proj-", "sk-", "Bearer ", "ghp_", "gho_", "github_pat_",
|
|
69
|
+
"xoxb-", "xoxp-", "AIza", "ya29.", "AKIA", "eyJ", # JWT
|
|
70
|
+
)
|
|
71
|
+
_CRED_REGEX = re.compile(r'^[A-Za-z0-9+/\-_]{32,}={0,2}$')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _classify(name: str) -> tuple[str, bool]:
|
|
75
|
+
lower = name.lower()
|
|
76
|
+
is_dangerous = any(p in lower for p in _DANGEROUS_PATTERNS)
|
|
77
|
+
is_write = is_dangerous or any(p in lower for p in _WRITE_PATTERNS)
|
|
78
|
+
return ("write" if is_write else "read"), is_dangerous
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _categorize(name: str) -> str:
|
|
82
|
+
lower = name.lower()
|
|
83
|
+
for category, patterns in _TOOL_CATEGORIES.items():
|
|
84
|
+
if any(p in lower for p in patterns):
|
|
85
|
+
return category
|
|
86
|
+
return "other"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_string(node: ast.expr | None) -> str:
|
|
90
|
+
if node is None:
|
|
91
|
+
return ""
|
|
92
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
93
|
+
return node.value
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _looks_like_credential(value: str) -> bool:
|
|
98
|
+
if any(value.startswith(p) for p in _CRED_PREFIXES):
|
|
99
|
+
return len(value) > 16
|
|
100
|
+
return bool(_CRED_REGEX.match(value)) and len(value) >= 32
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _has_decorator(
|
|
104
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
105
|
+
names: tuple[str, ...],
|
|
106
|
+
) -> bool:
|
|
107
|
+
for dec in func_node.decorator_list:
|
|
108
|
+
if isinstance(dec, ast.Name) and dec.id in names:
|
|
109
|
+
return True
|
|
110
|
+
if isinstance(dec, ast.Attribute) and dec.attr in names:
|
|
111
|
+
return True
|
|
112
|
+
if isinstance(dec, ast.Call):
|
|
113
|
+
if isinstance(dec.func, ast.Name) and dec.func.id in names:
|
|
114
|
+
return True
|
|
115
|
+
if isinstance(dec.func, ast.Attribute) and dec.func.attr in names:
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# AST visitor
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class _AgentFileVisitor(ast.NodeVisitor):
|
|
125
|
+
"""Walk an AST and collect tool definitions, agent metadata, and credentials."""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self.tools: list[ToolInfo] = []
|
|
129
|
+
self.description: str = ""
|
|
130
|
+
self.model: str = ""
|
|
131
|
+
self.hardcoded_creds: list[str] = []
|
|
132
|
+
self._seen: set[str] = set()
|
|
133
|
+
|
|
134
|
+
def _add_tool(self, name: str, source: str, docstring: str = "") -> None:
|
|
135
|
+
if name in self._seen:
|
|
136
|
+
return
|
|
137
|
+
self._seen.add(name)
|
|
138
|
+
scope, is_dangerous = _classify(name)
|
|
139
|
+
self.tools.append(ToolInfo(
|
|
140
|
+
name=name,
|
|
141
|
+
scope=scope,
|
|
142
|
+
is_dangerous=is_dangerous,
|
|
143
|
+
source=source,
|
|
144
|
+
docstring=docstring,
|
|
145
|
+
category=_categorize(name),
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# @tool / @SentinelTool decorated functions
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
153
|
+
if _has_decorator(node, ("tool", "SentinelTool", "beta_tool", "betaZodTool")):
|
|
154
|
+
doc = ast.get_docstring(node) or ""
|
|
155
|
+
self._add_tool(node.name, "@tool decorator", doc)
|
|
156
|
+
self.generic_visit(node)
|
|
157
|
+
|
|
158
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Class-based tools
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
165
|
+
base_names = {
|
|
166
|
+
b.id if isinstance(b, ast.Name) else
|
|
167
|
+
(b.attr if isinstance(b, ast.Attribute) else "")
|
|
168
|
+
for b in node.bases
|
|
169
|
+
}
|
|
170
|
+
if "BaseTool" in base_names or "StructuredTool" in base_names:
|
|
171
|
+
for item in node.body:
|
|
172
|
+
if (
|
|
173
|
+
isinstance(item, ast.Assign)
|
|
174
|
+
and any(isinstance(t, ast.Name) and t.id == "name" for t in item.targets)
|
|
175
|
+
):
|
|
176
|
+
tool_name = _get_string(item.value)
|
|
177
|
+
if tool_name:
|
|
178
|
+
doc = ast.get_docstring(node) or ""
|
|
179
|
+
self._add_tool(tool_name, "BaseTool subclass", doc)
|
|
180
|
+
self.generic_visit(node)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Tool() / StructuredTool() call-based tools + model + description
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
187
|
+
func_name = ""
|
|
188
|
+
if isinstance(node.func, ast.Name):
|
|
189
|
+
func_name = node.func.id
|
|
190
|
+
elif isinstance(node.func, ast.Attribute):
|
|
191
|
+
func_name = node.func.attr
|
|
192
|
+
|
|
193
|
+
if func_name in ("Tool", "StructuredTool"):
|
|
194
|
+
for kw in node.keywords:
|
|
195
|
+
if kw.arg == "name":
|
|
196
|
+
tool_name = _get_string(kw.value)
|
|
197
|
+
if tool_name:
|
|
198
|
+
self._add_tool(tool_name, f"{func_name}(name=...)")
|
|
199
|
+
|
|
200
|
+
if func_name in ("ChatAnthropic", "ChatOpenAI", "ChatGoogleGenerativeAI",
|
|
201
|
+
"AzureChatOpenAI", "BedrockChat", "init_chat_model"):
|
|
202
|
+
for kw in node.keywords:
|
|
203
|
+
if kw.arg == "model":
|
|
204
|
+
val = _get_string(kw.value)
|
|
205
|
+
if val and not self.model:
|
|
206
|
+
self.model = val
|
|
207
|
+
if node.args:
|
|
208
|
+
val = _get_string(node.args[0])
|
|
209
|
+
if val and any(val.startswith(p) for p in _KNOWN_MODELS) and not self.model:
|
|
210
|
+
self.model = val
|
|
211
|
+
|
|
212
|
+
if func_name == "create_agent" and node.args:
|
|
213
|
+
val = _get_string(node.args[0])
|
|
214
|
+
if val and not self.model:
|
|
215
|
+
self.model = val
|
|
216
|
+
|
|
217
|
+
if func_name == "SentinelCallbackHandler":
|
|
218
|
+
for kw in node.keywords:
|
|
219
|
+
if kw.arg == "description" and not self.description:
|
|
220
|
+
self.description = _get_string(kw.value)
|
|
221
|
+
|
|
222
|
+
self.generic_visit(node)
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Variable assignments — description detection + hardcoded credentials
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
229
|
+
for target in node.targets:
|
|
230
|
+
if not isinstance(target, ast.Name):
|
|
231
|
+
continue
|
|
232
|
+
varname = target.id.lower()
|
|
233
|
+
|
|
234
|
+
if "description" in varname and not self.description:
|
|
235
|
+
val = _get_string(node.value)
|
|
236
|
+
if val:
|
|
237
|
+
self.description = val
|
|
238
|
+
|
|
239
|
+
# Credential detection: api_key = "sk-ant-..."
|
|
240
|
+
if varname in _CRED_VARNAMES:
|
|
241
|
+
val = _get_string(node.value)
|
|
242
|
+
if val and _looks_like_credential(val):
|
|
243
|
+
self.hardcoded_creds.append(
|
|
244
|
+
f"{target.id} = \"{val[:8]}…\" (line {node.lineno})"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self.generic_visit(node)
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Keyword arguments — catch api_key="..." in function calls
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def visit_keyword(self, node: ast.keyword) -> None:
|
|
254
|
+
if node.arg and node.arg.lower() in _CRED_VARNAMES:
|
|
255
|
+
val = _get_string(node.value)
|
|
256
|
+
if val and _looks_like_credential(val):
|
|
257
|
+
self.hardcoded_creds.append(
|
|
258
|
+
f"{node.arg} = \"{val[:8]}…\" (line {node.value.lineno})" # type: ignore[attr-defined]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Public API
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def scan_file(path: Path) -> AgentInfo | None:
|
|
267
|
+
"""Parse a Python file and extract agent/tool information. Returns None on parse error."""
|
|
268
|
+
try:
|
|
269
|
+
source = path.read_text(encoding="utf-8")
|
|
270
|
+
tree = ast.parse(source, filename=str(path))
|
|
271
|
+
except (SyntaxError, OSError):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
visitor = _AgentFileVisitor()
|
|
275
|
+
visitor.visit(tree)
|
|
276
|
+
|
|
277
|
+
if not visitor.tools:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
return AgentInfo(
|
|
281
|
+
file=path,
|
|
282
|
+
tools=visitor.tools,
|
|
283
|
+
description=visitor.description,
|
|
284
|
+
model=visitor.model,
|
|
285
|
+
hardcoded_creds=visitor.hardcoded_creds,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def classify_tool(name: str, description: str = "") -> tuple[str, bool, str]:
|
|
290
|
+
"""Classify a tool by name and description into (scope, is_dangerous, category).
|
|
291
|
+
|
|
292
|
+
Used by both the static file scanner and the MCP scanner.
|
|
293
|
+
"""
|
|
294
|
+
combined = f"{name} {description}".strip()
|
|
295
|
+
scope, is_dangerous = _classify(combined)
|
|
296
|
+
category = _categorize(combined)
|
|
297
|
+
return scope, is_dangerous, category
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def scan_path(target: Path) -> list[AgentInfo]:
|
|
301
|
+
"""Scan a file or directory, returning one AgentInfo per file that contains tools."""
|
|
302
|
+
if target.is_file():
|
|
303
|
+
result = scan_file(target)
|
|
304
|
+
return [result] if result else []
|
|
305
|
+
|
|
306
|
+
results = []
|
|
307
|
+
for py_file in sorted(target.rglob("*.py")):
|
|
308
|
+
if any(part.startswith((".venv", "venv", "__pycache__", ".git", "node_modules"))
|
|
309
|
+
for part in py_file.parts):
|
|
310
|
+
continue
|
|
311
|
+
result = scan_file(py_file)
|
|
312
|
+
if result:
|
|
313
|
+
results.append(result)
|
|
314
|
+
return results
|