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
agentsentinel_cli/cli.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""AgentSentinel CLI — one-command security scanner and discovery tool for AI agents."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from agentsentinel_cli.scanner import scan_path
|
|
9
|
+
from agentsentinel_cli.rules import run_rules, posture_score
|
|
10
|
+
from agentsentinel_cli.report import print_scan_result, as_json, console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(package_name="agentsentinel-cli")
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""AgentSentinel — AI agent security scanner and discovery tool.
|
|
17
|
+
|
|
18
|
+
\b
|
|
19
|
+
Commands:
|
|
20
|
+
discover Find AI agents running in your environment
|
|
21
|
+
scan Deep-scan an agent file, process, or URL for security issues
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── sentinel scan ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.argument("target", default=".", type=click.Path(exists=True, path_type=Path))
|
|
29
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
30
|
+
help="Output format.")
|
|
31
|
+
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
32
|
+
default=None, help="Exit with code 1 if findings at or above this severity exist.")
|
|
33
|
+
@click.option("--connect", metavar="URL", default=None,
|
|
34
|
+
help="AgentSentinel API URL for live behavior data (e.g. http://localhost:9000).")
|
|
35
|
+
@click.option("--api-key", envvar="AGENTSENTINEL_API_KEY", default=None,
|
|
36
|
+
help="API key for --connect. Defaults to $AGENTSENTINEL_API_KEY.")
|
|
37
|
+
def scan(
|
|
38
|
+
target: Path,
|
|
39
|
+
fmt: str,
|
|
40
|
+
fail_on: str | None,
|
|
41
|
+
connect: str | None,
|
|
42
|
+
api_key: str | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Scan a Python file or directory for AI agent security issues.
|
|
45
|
+
|
|
46
|
+
TARGET can be a single .py file or a directory (scanned recursively).
|
|
47
|
+
|
|
48
|
+
\b
|
|
49
|
+
Examples:
|
|
50
|
+
sentinel scan my_agent.py
|
|
51
|
+
sentinel scan ./agents/
|
|
52
|
+
sentinel scan my_agent.py --fail-on CRITICAL
|
|
53
|
+
sentinel scan my_agent.py --format json
|
|
54
|
+
sentinel scan my_agent.py --connect http://localhost:9000
|
|
55
|
+
"""
|
|
56
|
+
agents = scan_path(target)
|
|
57
|
+
|
|
58
|
+
findings_map = {a.file: run_rules(a) for a in agents}
|
|
59
|
+
scores_map = {a.file: posture_score(findings_map[a.file]) for a in agents}
|
|
60
|
+
|
|
61
|
+
if connect and api_key and agents:
|
|
62
|
+
_enrich_from_platform(agents, scores_map, connect, api_key)
|
|
63
|
+
|
|
64
|
+
if fmt == "json":
|
|
65
|
+
click.echo(as_json(agents, findings_map, scores_map))
|
|
66
|
+
else:
|
|
67
|
+
print_scan_result(agents, findings_map, scores_map, target, connect_url=connect)
|
|
68
|
+
|
|
69
|
+
if fail_on:
|
|
70
|
+
_severity_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
71
|
+
threshold = _severity_rank.get(fail_on, 0)
|
|
72
|
+
breach = any(
|
|
73
|
+
_severity_rank.get(f.severity, 0) >= threshold
|
|
74
|
+
for fl in findings_map.values()
|
|
75
|
+
for f in fl
|
|
76
|
+
)
|
|
77
|
+
if breach:
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _enrich_from_platform(agents, scores_map, connect_url, api_key):
|
|
82
|
+
try:
|
|
83
|
+
import httpx
|
|
84
|
+
headers = {"X-API-Key": api_key}
|
|
85
|
+
base = connect_url.rstrip("/")
|
|
86
|
+
with httpx.Client(timeout=5.0) as client:
|
|
87
|
+
resp = client.get(f"{base}/api/v1/agents", headers=headers)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
platform_agents = {a["name"]: a for a in resp.json()}
|
|
90
|
+
for agent in agents:
|
|
91
|
+
candidate_name = agent.file.stem.replace("_", "-")
|
|
92
|
+
for name, data in platform_agents.items():
|
|
93
|
+
if candidate_name in name or name in candidate_name:
|
|
94
|
+
platform_score = data.get("trust_score", scores_map[agent.file])
|
|
95
|
+
scores_map[agent.file] = int(platform_score)
|
|
96
|
+
break
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
console.print(f" [dim yellow]Warning: could not connect to AgentSentinel: {exc}[/dim yellow]")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── sentinel discover ─────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
@main.command()
|
|
104
|
+
@click.option("--process/--no-process", default=True, show_default=True,
|
|
105
|
+
help="Scan running processes for LLM API usage.")
|
|
106
|
+
@click.option("--network/--no-network", default=True, show_default=True,
|
|
107
|
+
help="Probe local ports for MCP servers and agent APIs.")
|
|
108
|
+
@click.option("--docker/--no-docker", default=False, show_default=True,
|
|
109
|
+
help="Inspect running Docker containers.")
|
|
110
|
+
@click.option("--path", "scan_path", default=None, type=click.Path(exists=True, path_type=Path),
|
|
111
|
+
metavar="DIR", help="Scan a directory for agent source files.")
|
|
112
|
+
@click.option("--subnet", default=None, metavar="CIDR",
|
|
113
|
+
help="Scan a CIDR subnet for AI agent endpoints, e.g. 10.0.0.0/24.")
|
|
114
|
+
@click.option("--ports", default=None, metavar="RANGE",
|
|
115
|
+
help="Custom port range for network scan, e.g. 8000-9001. Defaults to common agent ports.")
|
|
116
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
117
|
+
help="Output format.")
|
|
118
|
+
@click.option("--verbose", "-v", is_flag=True, default=False,
|
|
119
|
+
help="Show full details per discovered agent.")
|
|
120
|
+
def discover(
|
|
121
|
+
process: bool,
|
|
122
|
+
network: bool,
|
|
123
|
+
docker: bool,
|
|
124
|
+
scan_path: Path | None,
|
|
125
|
+
subnet: str | None,
|
|
126
|
+
ports: str | None,
|
|
127
|
+
fmt: str,
|
|
128
|
+
verbose: bool,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Find AI agents running in your environment.
|
|
131
|
+
|
|
132
|
+
Scans running processes, local network ports, source files, and Docker
|
|
133
|
+
containers to surface AI agents — including unmonitored ones.
|
|
134
|
+
|
|
135
|
+
\b
|
|
136
|
+
Examples:
|
|
137
|
+
sentinel discover scan processes + network
|
|
138
|
+
sentinel discover --docker include Docker containers
|
|
139
|
+
sentinel discover --path ./agents scan a source directory
|
|
140
|
+
sentinel discover --subnet 10.0.0.0/24 scan internal subnet
|
|
141
|
+
sentinel discover --no-process network scan only
|
|
142
|
+
sentinel discover --ports 8000-9001 custom port range
|
|
143
|
+
sentinel discover --format json machine-readable output
|
|
144
|
+
"""
|
|
145
|
+
from agentsentinel_cli.discover import run_discovery, as_json as discover_json
|
|
146
|
+
from agentsentinel_cli.discover_report import print_discover_result, print_subnet_progress
|
|
147
|
+
|
|
148
|
+
# Parse port range
|
|
149
|
+
port_list = _parse_ports(ports) if ports else None
|
|
150
|
+
|
|
151
|
+
# Collect active scan vectors for the header
|
|
152
|
+
vectors = []
|
|
153
|
+
if process:
|
|
154
|
+
vectors.append("processes")
|
|
155
|
+
if network:
|
|
156
|
+
vectors.append("network")
|
|
157
|
+
if subnet:
|
|
158
|
+
vectors.append(f"subnet ({subnet})")
|
|
159
|
+
if scan_path:
|
|
160
|
+
vectors.append(f"files ({scan_path})")
|
|
161
|
+
if docker:
|
|
162
|
+
vectors.append("docker")
|
|
163
|
+
|
|
164
|
+
if not vectors:
|
|
165
|
+
console.print("[yellow]No scan vectors selected — use at least one of: "
|
|
166
|
+
"--process, --network, --subnet, --path, --docker[/yellow]")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
if fmt == "text":
|
|
170
|
+
_warn_missing_deps(process, network)
|
|
171
|
+
|
|
172
|
+
# Progress callback for subnet scan — only in text mode
|
|
173
|
+
progress_cb = print_subnet_progress if (subnet and fmt == "text") else None
|
|
174
|
+
|
|
175
|
+
agents, subnet_stats = run_discovery(
|
|
176
|
+
do_process=process,
|
|
177
|
+
do_network=network,
|
|
178
|
+
do_docker=docker,
|
|
179
|
+
scan_path=scan_path,
|
|
180
|
+
ports=port_list,
|
|
181
|
+
subnet=subnet,
|
|
182
|
+
subnet_progress_cb=progress_cb,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if fmt == "json":
|
|
186
|
+
click.echo(discover_json(agents))
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
print_discover_result(agents, vectors=vectors, verbose=verbose, subnet_stats=subnet_stats)
|
|
190
|
+
|
|
191
|
+
# Exit 1 if any CRITICAL agents found (useful for CI)
|
|
192
|
+
if any(a.risk == "CRITICAL" for a in agents):
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── sentinel mcp ──────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
@main.group(name="mcp")
|
|
199
|
+
def mcp_group() -> None:
|
|
200
|
+
"""MCP server security commands.
|
|
201
|
+
|
|
202
|
+
\b
|
|
203
|
+
Commands:
|
|
204
|
+
scan Enumerate an MCP server's tools and audit for security issues
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@mcp_group.command("scan")
|
|
209
|
+
@click.argument("target", default=None, required=False, metavar="URL")
|
|
210
|
+
@click.option("--stdio", "stdio_cmd", default=None, metavar="CMD",
|
|
211
|
+
help="Audit a stdio-transport server. Provide the launch command, e.g. 'python server.py'.")
|
|
212
|
+
@click.option("--auth-header", "auth_header", default=None, metavar="HEADER",
|
|
213
|
+
help="HTTP header to include, e.g. 'Authorization: Bearer token123'.")
|
|
214
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
215
|
+
help="Output format.")
|
|
216
|
+
@click.option("--timeout", default=10.0, show_default=True, metavar="SECONDS",
|
|
217
|
+
help="Connection timeout in seconds.")
|
|
218
|
+
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]), default=None,
|
|
219
|
+
help="Exit with code 1 if findings at or above this severity exist.")
|
|
220
|
+
def mcp_scan(
|
|
221
|
+
target: str | None,
|
|
222
|
+
stdio_cmd: str | None,
|
|
223
|
+
auth_header: str | None,
|
|
224
|
+
fmt: str,
|
|
225
|
+
timeout: float,
|
|
226
|
+
fail_on: str | None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Enumerate an MCP server's tools and audit for security issues.
|
|
229
|
+
|
|
230
|
+
Connects to the server, lists all exposed tools, and checks for
|
|
231
|
+
authentication gaps, exfiltration paths, code execution exposure,
|
|
232
|
+
and input validation weaknesses.
|
|
233
|
+
|
|
234
|
+
\b
|
|
235
|
+
Examples:
|
|
236
|
+
sentinel mcp scan http://localhost:3000
|
|
237
|
+
sentinel mcp scan http://localhost:3000 --auth-header "Authorization: Bearer token"
|
|
238
|
+
sentinel mcp scan --stdio "python my_mcp_server.py"
|
|
239
|
+
sentinel mcp scan http://localhost:3000 --format json
|
|
240
|
+
sentinel mcp scan http://localhost:3000 --fail-on CRITICAL
|
|
241
|
+
"""
|
|
242
|
+
from agentsentinel_cli.mcp_client import scan_http, scan_stdio, McpError, McpAuthRequired
|
|
243
|
+
from agentsentinel_cli.mcp_rules import McpContext, run_mcp_rules, mcp_posture_score
|
|
244
|
+
from agentsentinel_cli.mcp_report import print_mcp_result, as_mcp_json
|
|
245
|
+
|
|
246
|
+
if not target and not stdio_cmd:
|
|
247
|
+
console.print("[red]Error:[/red] provide a URL target or --stdio CMD.")
|
|
248
|
+
console.print(" Example: [dim]sentinel mcp scan http://localhost:3000[/dim]")
|
|
249
|
+
console.print(" Example: [dim]sentinel mcp scan --stdio 'python server.py'[/dim]")
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
if target and stdio_cmd:
|
|
252
|
+
console.print("[red]Error:[/red] --stdio and a URL target are mutually exclusive.")
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
display_target = stdio_cmd if stdio_cmd else target
|
|
256
|
+
|
|
257
|
+
extra_headers: dict[str, str] = {}
|
|
258
|
+
if auth_header:
|
|
259
|
+
if ":" not in auth_header:
|
|
260
|
+
console.print("[red]Error:[/red] --auth-header must be in 'Header-Name: value' format.")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
key, _, val = auth_header.partition(":")
|
|
263
|
+
extra_headers[key.strip()] = val.strip()
|
|
264
|
+
|
|
265
|
+
auth_required = bool(auth_header)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
if stdio_cmd:
|
|
269
|
+
server = scan_stdio(stdio_cmd, timeout=timeout)
|
|
270
|
+
auth_required = False # stdio has no network auth concept
|
|
271
|
+
else:
|
|
272
|
+
server = scan_http(target, extra_headers=extra_headers or None, timeout=timeout)
|
|
273
|
+
except McpAuthRequired as exc:
|
|
274
|
+
console.print(f"\n[bold yellow]Authentication required[/bold yellow] (HTTP {exc.status_code})")
|
|
275
|
+
console.print(
|
|
276
|
+
" Provide credentials with: "
|
|
277
|
+
"[bold]--auth-header 'Authorization: Bearer <token>'[/bold]"
|
|
278
|
+
)
|
|
279
|
+
sys.exit(1)
|
|
280
|
+
except McpError as exc:
|
|
281
|
+
console.print(f"\n[red]MCP connection failed:[/red] {exc}")
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
console.print(f"\n[red]Unexpected error:[/red] {exc}")
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
ctx = McpContext(server=server, auth_required=auth_required)
|
|
288
|
+
findings = run_mcp_rules(ctx)
|
|
289
|
+
score = mcp_posture_score(findings)
|
|
290
|
+
|
|
291
|
+
if fmt == "json":
|
|
292
|
+
click.echo(as_mcp_json(ctx, findings, score, display_target))
|
|
293
|
+
else:
|
|
294
|
+
print_mcp_result(ctx, findings, score, display_target)
|
|
295
|
+
|
|
296
|
+
if fail_on:
|
|
297
|
+
_severity_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
298
|
+
threshold = _severity_rank.get(fail_on, 0)
|
|
299
|
+
if any(_severity_rank.get(f.severity, 0) >= threshold for f in findings):
|
|
300
|
+
sys.exit(1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_ports(ports_str: str) -> list[int]:
|
|
304
|
+
"""Parse '8000-9001' or '8000,8080,9000' into a list of ints."""
|
|
305
|
+
ports: list[int] = []
|
|
306
|
+
for part in ports_str.split(","):
|
|
307
|
+
part = part.strip()
|
|
308
|
+
if "-" in part:
|
|
309
|
+
lo, _, hi = part.partition("-")
|
|
310
|
+
try:
|
|
311
|
+
ports.extend(range(int(lo), int(hi) + 1))
|
|
312
|
+
except ValueError:
|
|
313
|
+
pass
|
|
314
|
+
else:
|
|
315
|
+
try:
|
|
316
|
+
ports.append(int(part))
|
|
317
|
+
except ValueError:
|
|
318
|
+
pass
|
|
319
|
+
return ports
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _warn_missing_deps(do_process: bool, do_network: bool) -> None:
|
|
323
|
+
if do_process:
|
|
324
|
+
try:
|
|
325
|
+
import psutil # noqa: F401
|
|
326
|
+
except ImportError:
|
|
327
|
+
console.print(
|
|
328
|
+
"[dim yellow] ⚠ psutil not installed — process scan disabled.[/dim yellow]\n"
|
|
329
|
+
"[dim] Install with: pip install agentsentinel-cli\\[discover][/dim]\n"
|
|
330
|
+
)
|
|
331
|
+
if do_network:
|
|
332
|
+
try:
|
|
333
|
+
import httpx # noqa: F401
|
|
334
|
+
except ImportError:
|
|
335
|
+
console.print(
|
|
336
|
+
"[dim yellow] ⚠ httpx not installed — network probe disabled.[/dim yellow]\n"
|
|
337
|
+
"[dim] Install with: pip install agentsentinel-cli\\[discover][/dim]\n"
|
|
338
|
+
)
|