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.
@@ -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