agentsentinel-cli 0.7.1__tar.gz → 0.7.3__tar.gz

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.
Files changed (42) hide show
  1. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/PKG-INFO +1 -1
  2. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/cli.py +52 -24
  3. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/discover.py +147 -230
  4. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/discover_report.py +13 -5
  5. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/fingerprint.py +42 -12
  6. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/inspect.py +11 -2
  7. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/inspect_report.py +12 -7
  8. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_client.py +2 -2
  9. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/pyproject.toml +1 -1
  10. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/.gitignore +0 -0
  11. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/DOCUMENTATION.md +0 -0
  12. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/LICENSE +0 -0
  13. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/README.md +0 -0
  14. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/__init__.py +0 -0
  15. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_report.py +0 -0
  16. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_rules.py +0 -0
  17. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/a2a_scanner.py +0 -0
  18. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/agent_mode.py +0 -0
  19. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/agent_mode_report.py +0 -0
  20. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/ai_probe.py +0 -0
  21. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/attacks/__init__.py +0 -0
  22. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/attacks/library.py +0 -0
  23. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/frameworks.py +0 -0
  24. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_report.py +0 -0
  25. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/mcp_rules.py +0 -0
  26. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/probe.py +0 -0
  27. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/probe_report.py +0 -0
  28. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/report.py +0 -0
  29. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/rules.py +0 -0
  30. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/scanner.py +0 -0
  31. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets.py +0 -0
  32. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets_report.py +0 -0
  33. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/secrets_rules.py +0 -0
  34. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_ai.py +0 -0
  35. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_report.py +0 -0
  36. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/supply_chain_rules.py +0 -0
  37. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/suppress.py +0 -0
  38. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/agentsentinel_cli/target.py +0 -0
  39. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/README.md +0 -0
  40. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/langchain_agent.py +0 -0
  41. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/mcp_server.py +0 -0
  42. {agentsentinel_cli-0.7.1 → agentsentinel_cli-0.7.3}/tmp/test-mcp-agent/requirements.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentsentinel-cli
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery
5
5
  Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
6
6
  Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
@@ -123,52 +123,82 @@ def _enrich_from_platform(agents, scores_map, connect_url, api_key):
123
123
 
124
124
  @main.command()
125
125
  @click.option("--process/--no-process", default=True, show_default=True,
126
- help="Scan running processes for LLM API usage.")
126
+ help="Scan running processes for MCP servers and agent signals.")
127
127
  @click.option("--network/--no-network", default=True, show_default=True,
128
- help="Probe local ports for MCP servers and agent APIs.")
128
+ help="Probe local ports confirmed via MCP protocol handshake.")
129
129
  @click.option("--docker/--no-docker", default=False, show_default=True,
130
- help="Inspect running Docker containers.")
131
- @click.option("--path", "scan_path", default=None, type=click.Path(exists=True, path_type=Path),
132
- metavar="DIR", help="Scan a directory for agent source files.")
130
+ help="Inspect running Docker containers for MCP/agent patterns.")
131
+ @click.option("--host", default=None, metavar="IP",
132
+ help="Scan a single host, e.g. 10.0.1.45.")
133
133
  @click.option("--subnet", default=None, metavar="CIDR",
134
- help="Scan a CIDR subnet for AI agent endpoints, e.g. 10.0.0.0/24.")
134
+ help="Scan a CIDR subnet for MCP servers, e.g. 10.0.0.0/24.")
135
135
  @click.option("--ports", default=None, metavar="RANGE",
136
- help="Custom port range for network scan, e.g. 8000-9001. Defaults to common agent ports.")
136
+ help="Custom port range, e.g. 8000-9001. Defaults to common MCP/agent ports.")
137
+ @click.option("--auth-header", "auth_header", default=None, metavar="HEADER",
138
+ help="HTTP auth header for MCP handshakes, e.g. 'Authorization: Bearer token'.")
137
139
  @click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
138
140
  help="Output format.")
139
141
  @click.option("--verbose", "-v", is_flag=True, default=False,
140
- help="Show full details per discovered agent.")
142
+ help="Show full details per discovered server.")
141
143
  def discover(
142
144
  process: bool,
143
145
  network: bool,
144
146
  docker: bool,
145
- scan_path: Path | None,
147
+ host: str | None,
146
148
  subnet: str | None,
147
149
  ports: str | None,
150
+ auth_header: str | None,
148
151
  fmt: str,
149
152
  verbose: bool,
150
153
  ) -> None:
151
- """Find AI agents running in your environment.
154
+ """Find MCP servers and AI agent processes in your environment.
152
155
 
153
- Scans running processes, local network ports, source files, and Docker
154
- containers to surface AI agents — including unmonitored ones.
156
+ Confirms MCP servers via protocol handshake not just open ports.
155
157
 
156
158
  \b
157
159
  Examples:
158
- sentinel discover scan processes + network
159
- sentinel discover --docker include Docker containers
160
- sentinel discover --path ./agents scan a source directory
161
- sentinel discover --subnet 10.0.0.0/24 scan internal subnet
162
- sentinel discover --no-process network scan only
163
- sentinel discover --ports 8000-9001 custom port range
164
- sentinel discover --format json machine-readable output
160
+ sentinel discover local processes + ports
161
+ sentinel discover --host 10.0.1.45 single remote host
162
+ sentinel discover --subnet 10.0.0.0/24 full subnet scan
163
+ sentinel discover --subnet 10.0.0.0/24 \\
164
+ --auth-header 'Authorization: Bearer token' scan with credentials
165
+ sentinel discover --no-process network only
166
+ sentinel discover --docker include containers
167
+ sentinel discover --ports 8000-9001 custom port range
168
+ sentinel discover --format json machine-readable output
165
169
  """
166
- from agentsentinel_cli.discover import run_discovery, as_json as discover_json
170
+ from agentsentinel_cli.discover import run_discovery, scan_network, as_json as discover_json
167
171
  from agentsentinel_cli.discover_report import print_discover_result, print_subnet_progress
168
172
 
169
173
  # Parse port range
170
174
  port_list = _parse_ports(ports) if ports else None
171
175
 
176
+ # Parse auth header
177
+ extra_headers: dict[str, str] = {}
178
+ if auth_header:
179
+ if ":" not in auth_header:
180
+ console.print("[red]Error:[/red] --auth-header must be 'Header-Name: value' format.")
181
+ sys.exit(1)
182
+ key, _, val = auth_header.partition(":")
183
+ extra_headers[key.strip()] = val.strip()
184
+
185
+ # --host: single-host scan — bypass run_discovery and call scan_network directly
186
+ if host:
187
+ if fmt == "text":
188
+ _warn_missing_deps(False, True)
189
+ agents = scan_network(
190
+ host=host,
191
+ ports=port_list,
192
+ extra_headers=extra_headers or None,
193
+ )
194
+ if fmt == "json":
195
+ click.echo(discover_json(agents))
196
+ return
197
+ print_discover_result(agents, vectors=[f"host ({host})"], verbose=verbose)
198
+ if any(a.risk == "CRITICAL" for a in agents):
199
+ sys.exit(1)
200
+ return
201
+
172
202
  # Collect active scan vectors for the header
173
203
  vectors = []
174
204
  if process:
@@ -177,14 +207,12 @@ def discover(
177
207
  vectors.append("network")
178
208
  if subnet:
179
209
  vectors.append(f"subnet ({subnet})")
180
- if scan_path:
181
- vectors.append(f"files ({scan_path})")
182
210
  if docker:
183
211
  vectors.append("docker")
184
212
 
185
213
  if not vectors:
186
214
  console.print("[yellow]No scan vectors selected — use at least one of: "
187
- "--process, --network, --subnet, --path, --docker[/yellow]")
215
+ "--process, --network, --host, --subnet, --docker[/yellow]")
188
216
  sys.exit(1)
189
217
 
190
218
  if fmt == "text":
@@ -197,9 +225,9 @@ def discover(
197
225
  do_process=process,
198
226
  do_network=network,
199
227
  do_docker=docker,
200
- scan_path=scan_path,
201
228
  ports=port_list,
202
229
  subnet=subnet,
230
+ extra_headers=extra_headers or None,
203
231
  subnet_progress_cb=progress_cb,
204
232
  )
205
233
 
@@ -1,12 +1,11 @@
1
1
  """
2
- sentinel discover — finds AI agents across processes, network, files, and Docker containers.
2
+ sentinel discover — find MCP servers and AI agent processes.
3
3
 
4
4
  Scan vectors:
5
- process running Python/Node processes making LLM API calls
6
- network open ports serving MCP SSE endpoints or agent APIs
7
- subnet CIDR subnet scan finds agents across an internal network
8
- files Python source files in a directory containing agent patterns
9
- docker Docker containers with LLM API keys in their environment
5
+ process running Python/Node processes serving MCP or calling LLM APIs
6
+ network open ports on localhost confirmed as MCP via protocol handshake
7
+ subnet CIDR subnet — TCP sweep then MCP handshake on every open port
8
+ docker Docker containers with MCP server or LLM agent patterns
10
9
  """
11
10
 
12
11
  from __future__ import annotations
@@ -34,17 +33,19 @@ from agentsentinel_cli.frameworks import (
34
33
 
35
34
  @dataclasses.dataclass
36
35
  class DiscoveredAgent:
37
- source: str # process | network | subnet | file | docker
36
+ source: str # process | network | subnet | docker
38
37
  name: str # human-readable name
39
- framework: str # LangChain | OpenAI Agents SDK | MCP | etc.
38
+ framework: str # FastMCP | LangChain | AutoGen | etc.
40
39
  provider: str # Anthropic | OpenAI | Google | etc.
41
40
  model: str # claude-sonnet | gpt-4o | etc. (empty if unknown)
42
- location: str # pid:1234 | 10.0.1.45:8080 | /path/file.py | container:name
41
+ location: str # pid:1234 | 10.0.1.45:8080 | container:name
43
42
  api_keys: list[str] # masked keys: ANTHROPIC_API_KEY=sk-ant-...5f3d
44
43
  live_connections: list[str] # LLM API hosts this process is talking to
45
44
  risk: str # CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN
46
45
  risk_reason: str # one-line explanation of the risk level
47
46
  next_step: str # suggested follow-up command
47
+ tools: list[str] = dataclasses.field(default_factory=list) # tool names (MCP enumeration)
48
+ transport: str = "" # "http" | "sse" | "" (empty for non-MCP)
48
49
 
49
50
 
50
51
  @dataclasses.dataclass
@@ -68,10 +69,6 @@ _DEFAULT_PORTS = [
68
69
  11434, # Ollama
69
70
  ]
70
71
 
71
- _MCP_INDICATOR_PATHS = ["/sse", "/messages/"]
72
- _OPENAI_COMPAT_PATHS = ["/v1/models"]
73
- _SENTINEL_PATHS = ["/api/v1/agents", "/health"]
74
-
75
72
  # Subnet scan limits — scanning beyond these sizes is impractical
76
73
  _MAX_SUBNET_HOSTS_WARN = 1024 # /22 — warn but allow
77
74
  _MAX_SUBNET_HOSTS_BLOCK = 65536 # /16 — refuse (too slow, too noisy)
@@ -221,8 +218,9 @@ def scan_network(
221
218
  host: str = "127.0.0.1",
222
219
  ports: list[int] | None = None,
223
220
  timeout: float = 0.5,
221
+ extra_headers: dict[str, str] | None = None,
224
222
  ) -> list[DiscoveredAgent]:
225
- """Probe a single host's ports for AI agent endpoints."""
223
+ """Probe a single host's ports confirms MCP via protocol handshake."""
226
224
  if ports is None:
227
225
  ports = _DEFAULT_PORTS
228
226
 
@@ -230,11 +228,20 @@ def scan_network(
230
228
  if not open_ports:
231
229
  return []
232
230
 
231
+ # SSE handshake needs much more time than a TCP connect — use a fixed floor
232
+ # of 8 seconds regardless of the port-sweep timeout.
233
+ handshake_timeout = max(timeout * 16, 8.0)
234
+
233
235
  found: list[DiscoveredAgent] = []
234
- for port in open_ports:
235
- agent = _probe_port(host, port, timeout * 4)
236
- if agent:
237
- found.append(agent)
236
+ with ThreadPoolExecutor(max_workers=min(10, len(open_ports))) as pool:
237
+ futures = {
238
+ pool.submit(_probe_mcp, host, port, handshake_timeout, extra_headers): port
239
+ for port in open_ports
240
+ }
241
+ for future in as_completed(futures):
242
+ agent = future.result()
243
+ if agent:
244
+ found.append(agent)
238
245
  return found
239
246
 
240
247
 
@@ -273,19 +280,24 @@ def scan_subnet(
273
280
  cidr: str,
274
281
  ports: list[int] | None = None,
275
282
  timeout: float = 0.3,
276
- on_progress: Optional[Callable[[int, int, str], None]] = None,
283
+ extra_headers: dict[str, str] | None = None,
284
+ on_progress: Optional[Callable[[int, int, str, str], None]] = None,
277
285
  ) -> tuple[list[DiscoveredAgent], SubnetScanStats]:
278
- """Scan every host in a CIDR subnet for AI agent endpoints.
286
+ """Scan every host in a CIDR subnet for MCP servers.
279
287
 
280
- Uses a two-phase approach:
281
- Phase 1 — parallel TCP connect across all host:port combinations (fast)
282
- Phase 2 — HTTP probe on every open port to identify agent type (targeted)
288
+ Two phases:
289
+ Phase 1 — parallel TCP connect across all host:port pairs (fast sweep)
290
+ Phase 2 — MCP protocol handshake on every open port (targeted verification)
291
+
292
+ A result in Phase 2 means the MCP initialize exchange completed — not just
293
+ that a port was open.
283
294
 
284
295
  Args:
285
- cidr: Network range, e.g. "10.0.0.0/24"
286
- ports: Port list to probe (default: _DEFAULT_PORTS)
287
- timeout: Per-connection TCP timeout in seconds
288
- on_progress: Optional callback(completed, total, current_ip) for progress display
296
+ cidr: Network range, e.g. "10.0.0.0/24"
297
+ ports: Port list to probe (default: _DEFAULT_PORTS)
298
+ timeout: Per-connection TCP timeout in seconds
299
+ extra_headers: HTTP headers for MCP handshake (auth tokens, etc.)
300
+ on_progress: Optional callback(completed, total, current_ip, phase)
289
301
 
290
302
  Returns:
291
303
  (agents, stats) tuple
@@ -302,7 +314,7 @@ def scan_subnet(
302
314
  if len(hosts) > _MAX_SUBNET_HOSTS_BLOCK:
303
315
  raise ValueError(
304
316
  f"{cidr} contains {len(hosts):,} hosts. "
305
- f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,} (/{32 - _MAX_SUBNET_HOSTS_BLOCK.bit_length() + 1}). "
317
+ f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,}. "
306
318
  "Use a smaller subnet or scan individual host ranges."
307
319
  )
308
320
 
@@ -310,8 +322,7 @@ def scan_subnet(
310
322
  total_probes = len(hosts) * len(ports)
311
323
  open_targets: list[tuple[str, int]] = []
312
324
 
313
- # ── Phase 1: parallel TCP connect across all host:port pairs ─────────────
314
- # High concurrency — most connections refuse immediately, failures are cheap.
325
+ # ── Phase 1: parallel TCP connect ────────────────────────────────────────
315
326
  completed = 0
316
327
  workers = min(250, total_probes)
317
328
 
@@ -323,25 +334,36 @@ def scan_subnet(
323
334
  return None
324
335
 
325
336
  tasks = [(h, p) for h in hosts for p in ports]
326
- futures = {}
327
337
 
328
338
  with ThreadPoolExecutor(max_workers=workers) as pool:
329
- futures = {pool.submit(check_port, h, p): (h, p) for h, p in tasks}
330
- for future in as_completed(futures):
339
+ futures_p1 = {pool.submit(check_port, h, p): (h, p) for h, p in tasks}
340
+ for future in as_completed(futures_p1):
331
341
  result = future.result()
332
342
  if result:
333
343
  open_targets.append(result)
334
344
  completed += 1
335
345
  if on_progress:
336
- host_ip, _ = futures[future]
337
- on_progress(completed, total_probes, host_ip)
346
+ host_ip, _ = futures_p1[future]
347
+ on_progress(completed, total_probes, host_ip, "1")
338
348
 
339
- # ── Phase 2: HTTP probe on open ports to identify agent type ─────────────
349
+ # ── Phase 2: MCP protocol handshake on open ports ─────────────────────
340
350
  found: list[DiscoveredAgent] = []
341
- for host_ip, port in open_targets:
342
- agent = _probe_port(host_ip, port, timeout * 8)
343
- if agent:
344
- found.append(agent)
351
+ if open_targets:
352
+ p2_workers = min(20, len(open_targets))
353
+ with ThreadPoolExecutor(max_workers=p2_workers) as pool:
354
+ futures_p2 = {
355
+ pool.submit(_probe_mcp, h, p, timeout * 10, extra_headers): (h, p)
356
+ for h, p in open_targets
357
+ }
358
+ p2_done = 0
359
+ for future in as_completed(futures_p2):
360
+ agent = future.result()
361
+ if agent:
362
+ found.append(agent)
363
+ p2_done += 1
364
+ if on_progress:
365
+ h, p = futures_p2[future]
366
+ on_progress(p2_done, len(open_targets), f"{h}:{p}", "2")
345
367
 
346
368
  elapsed = time.monotonic() - started_at
347
369
  stats = SubnetScanStats(
@@ -355,199 +377,96 @@ def scan_subnet(
355
377
  return found, stats
356
378
 
357
379
 
358
- # ── Port prober ───────────────────────────────────────────────────────────────
380
+ # ── MCP protocol prober ───────────────────────────────────────────────────────
359
381
 
360
- def _probe_port(host: str, port: int, timeout: float) -> DiscoveredAgent | None:
361
- """Make HTTP requests to an open port and detect what kind of agent it is."""
362
- try:
363
- import httpx
364
- except ImportError:
365
- return None
382
+ def _probe_mcp(
383
+ host: str,
384
+ port: int,
385
+ timeout: float,
386
+ extra_headers: dict[str, str] | None = None,
387
+ ) -> DiscoveredAgent | None:
388
+ """Confirm a port is an MCP server by completing the initialize handshake.
389
+
390
+ Tries streamable-HTTP (POST) first; falls back to SSE (GET /sse) automatically.
391
+ A non-None return means the MCP protocol exchange succeeded or the server
392
+ explicitly rejected us with 401/403 — both confirm an MCP server is present.
393
+ """
394
+ from agentsentinel_cli.mcp_client import scan_http, McpAuthRequired, McpError
366
395
 
367
396
  base = f"http://{host}:{port}"
368
397
  location = f"{host}:{port}"
369
398
 
370
- with httpx.Client(timeout=timeout, follow_redirects=False) as client:
371
-
372
- # MCP SSE server
373
- for path in _MCP_INDICATOR_PATHS:
374
- try:
375
- r = client.get(f"{base}{path}", headers={"Accept": "text/event-stream"})
376
- if r.status_code < 500 and (
377
- "text/event-stream" in r.headers.get("content-type", "")
378
- or "event-stream" in r.text[:200]
379
- or r.status_code == 200
380
- ):
381
- return DiscoveredAgent(
382
- source="network",
383
- name=f"mcp-server@{location}",
384
- framework="MCP Server",
385
- provider="",
386
- model="",
387
- location=location,
388
- api_keys=[],
389
- live_connections=[],
390
- risk="HIGH",
391
- risk_reason="MCP server with no authentication detected — inspect tools",
392
- next_step=f"sentinel mcp scan {base}/sse",
393
- )
394
- except httpx.RequestError:
395
- pass
396
-
397
- # OpenAI-compatible API (Ollama, LiteLLM, vLLM, etc.)
398
- for path in _OPENAI_COMPAT_PATHS:
399
- try:
400
- r = client.get(f"{base}{path}")
401
- if r.status_code in (200, 401) and _looks_like_openai_api(r):
402
- model = _extract_model_from_response(r)
403
- auth_required = r.status_code == 401
404
- return DiscoveredAgent(
405
- source="network",
406
- name=f"llm-api@{location}",
407
- framework="OpenAI-compatible API",
408
- provider="",
409
- model=model,
410
- location=location,
411
- api_keys=[],
412
- live_connections=[],
413
- risk="LOW" if auth_required else "MEDIUM",
414
- risk_reason=(
415
- "OpenAI-compatible API (auth required)"
416
- if auth_required
417
- else "OpenAI-compatible API with no authentication — open access"
418
- ),
419
- next_step=f"sentinel scan --url {base}",
420
- )
421
- except httpx.RequestError:
422
- pass
423
-
424
- # AgentSentinel platform
425
- try:
426
- r = client.get(f"{base}/health")
427
- if r.status_code == 200 and r.text.strip().startswith("{"):
428
- body = r.json()
429
- if "status" in body:
430
- return DiscoveredAgent(
431
- source="network",
432
- name=f"agentsentinel@{location}",
433
- framework="AgentSentinel",
434
- provider="",
435
- model="",
436
- location=location,
437
- api_keys=[],
438
- live_connections=[],
439
- risk="LOW",
440
- risk_reason="AgentSentinel monitoring platform",
441
- next_step=f"sentinel scan --connect http://{location}",
442
- )
443
- except (httpx.RequestError, Exception):
444
- pass
445
-
446
- # Generic agent API (LangChain server, FastAPI agent, etc.)
447
- try:
448
- r = client.get(f"{base}/api/v1/agents")
449
- if r.status_code in (200, 401, 403):
450
- auth_required = r.status_code in (401, 403)
451
- return DiscoveredAgent(
452
- source="network",
453
- name=f"agent-api@{location}",
454
- framework="Unknown Agent API",
455
- provider="",
456
- model="",
457
- location=location,
458
- api_keys=[],
459
- live_connections=[],
460
- risk="MEDIUM" if not auth_required else "LOW",
461
- risk_reason=(
462
- "Agent API endpoint detected (auth required)"
463
- if auth_required
464
- else "Agent API endpoint with no authentication"
465
- ),
466
- next_step=f"sentinel scan --url {base}",
467
- )
468
- except httpx.RequestError:
469
- pass
470
-
471
- return None
472
-
473
-
474
- def _looks_like_openai_api(response) -> bool:
475
- try:
476
- body = response.json()
477
- return "data" in body or "models" in body or "object" in body or "error" in body
478
- except Exception:
479
- return False
480
-
481
-
482
- def _extract_model_from_response(response) -> str:
483
399
  try:
484
- data = response.json().get("data", [])
485
- if data:
486
- return data[0].get("id", "")
487
- except Exception:
488
- pass
489
- return ""
490
-
491
-
492
- # ── File scanner ──────────────────────────────────────────────────────────────
493
-
494
- def scan_files(path: Path) -> list[DiscoveredAgent]:
495
- """Find Python files in a directory that look like AI agents."""
496
- from agentsentinel_cli.scanner import scan_path as static_scan
497
-
498
- agents = static_scan(path)
499
- found: list[DiscoveredAgent] = []
500
-
501
- for agent in agents:
502
- framework, provider = detect_framework(agent.file.read_text(errors="ignore"))
503
- model = agent.model or detect_model(agent.file.read_text(errors="ignore"))
504
- tool_count = len(agent.tools)
505
- has_dangerous = any(t.is_dangerous for t in agent.tools)
506
- has_creds = bool(agent.hardcoded_creds)
507
-
508
- risk, risk_reason = _assess_file_risk(has_creds, has_dangerous, tool_count, framework)
509
-
510
- found.append(DiscoveredAgent(
511
- source="file",
512
- name=agent.file.stem.replace("_", "-"),
513
- framework=framework if framework != "Unknown" else _infer_framework_from_tools(agent),
514
- provider=provider,
515
- model=model,
516
- location=str(agent.file),
517
- api_keys=[f"HARDCODED: {c}" for c in agent.hardcoded_creds],
400
+ server = scan_http(base, extra_headers=extra_headers, timeout=timeout)
401
+ except McpAuthRequired:
402
+ # Server is MCP — it understood our handshake but requires credentials
403
+ scan_url = f"{base}/sse" if True else base # SSE is dominant transport
404
+ return DiscoveredAgent(
405
+ source="network",
406
+ name=f"mcp-server@{location}",
407
+ framework="MCP Server",
408
+ provider="",
409
+ model="",
410
+ location=location,
411
+ api_keys=[],
518
412
  live_connections=[],
519
- risk=risk,
520
- risk_reason=risk_reason,
521
- next_step=f"sentinel scan {agent.file}",
522
- ))
523
-
524
- return found
525
-
526
-
527
- def _infer_framework_from_tools(agent) -> str:
528
- sources = {t.source for t in agent.tools}
529
- if "BaseTool subclass" in sources or "StructuredTool" in str(sources):
530
- return "LangChain"
531
- if "@tool decorator" in sources:
532
- return "LangChain / CrewAI"
533
- return "Python agent"
413
+ risk="MEDIUM",
414
+ risk_reason="MCP server confirmed — authentication required, tools not enumerated",
415
+ next_step=(
416
+ f"sentinel mcp scan {base}/sse --auth-header 'Authorization: Bearer <token>'"
417
+ ),
418
+ tools=[],
419
+ transport="",
420
+ )
421
+ except McpError:
422
+ return None
423
+ except Exception:
424
+ return None
534
425
 
426
+ # Handshake succeeded — assess risk based on actual tool content
427
+ tool_names = [t.name for t in server.tools]
428
+ has_dangerous = any(t.is_dangerous for t in server.tools)
429
+ has_write = any(t.scope == "write" for t in server.tools)
430
+ auth_present = bool(extra_headers)
431
+
432
+ if not auth_present:
433
+ if has_dangerous or has_write:
434
+ risk = "CRITICAL"
435
+ risk_reason = (
436
+ f"Unauthenticated MCP server with dangerous/write tools: "
437
+ f"{', '.join(t.name for t in server.tools if t.is_dangerous or t.scope == 'write')}"
438
+ )
439
+ else:
440
+ risk = "HIGH"
441
+ risk_reason = (
442
+ f"Unauthenticated MCP server — {len(server.tools)} tool"
443
+ f"{'s' if len(server.tools) != 1 else ''} publicly accessible"
444
+ )
445
+ else:
446
+ risk = "LOW"
447
+ risk_reason = f"MCP server (authenticated) — {len(server.tools)} tool{'s' if len(server.tools) != 1 else ''} enumerated"
448
+
449
+ scan_url = f"{base}/sse" if server.transport == "sse" else base
450
+ auth_flag = (
451
+ f" --auth-header '{next(iter(extra_headers.items()))[0]}: ...'"
452
+ if extra_headers else ""
453
+ )
535
454
 
536
- def _assess_file_risk(
537
- has_creds: bool,
538
- has_dangerous: bool,
539
- tool_count: int,
540
- framework: str,
541
- ) -> tuple[str, str]:
542
- if has_creds:
543
- return "CRITICAL", "Hardcoded credentials detected in source code — rotate immediately"
544
- if has_dangerous:
545
- return "HIGH", "Agent holds dangerous tool grants — run full scan"
546
- if tool_count > 10:
547
- return "MEDIUM", f"{tool_count} tool grants — excessive permissions, high blast radius"
548
- if tool_count > 0:
549
- return "LOW", f"{tool_count} tool grant{'s' if tool_count != 1 else ''} detected"
550
- return "UNKNOWN", "Agent file detected — run full scan for analysis"
455
+ return DiscoveredAgent(
456
+ source="network",
457
+ name=server.name if server.name != "unknown" else f"mcp-server@{location}",
458
+ framework=f"MCP Server ({server.transport.upper()})",
459
+ provider="",
460
+ model="",
461
+ location=location,
462
+ api_keys=[],
463
+ live_connections=[],
464
+ risk=risk,
465
+ risk_reason=risk_reason,
466
+ next_step=f"sentinel mcp scan {scan_url}{auth_flag}",
467
+ tools=tool_names,
468
+ transport=server.transport,
469
+ )
551
470
 
552
471
 
553
472
  # ── Docker scanner ────────────────────────────────────────────────────────────
@@ -651,10 +570,10 @@ def run_discovery(
651
570
  do_process: bool = True,
652
571
  do_network: bool = True,
653
572
  do_docker: bool = False,
654
- scan_path: Optional[Path] = None,
655
573
  ports: list[int] | None = None,
656
574
  subnet: Optional[str] = None,
657
- subnet_progress_cb: Optional[Callable[[int, int, str], None]] = None,
575
+ extra_headers: dict[str, str] | None = None,
576
+ subnet_progress_cb: Optional[Callable[[int, int, str, str], None]] = None,
658
577
  ) -> tuple[list[DiscoveredAgent], Optional[SubnetScanStats]]:
659
578
  """Run all requested discovery scanners.
660
579
 
@@ -667,19 +586,17 @@ def run_discovery(
667
586
  results.extend(scan_processes())
668
587
 
669
588
  if do_network:
670
- results.extend(scan_network(ports=ports))
589
+ results.extend(scan_network(ports=ports, extra_headers=extra_headers))
671
590
 
672
591
  if subnet:
673
592
  agents, subnet_stats = scan_subnet(
674
593
  cidr=subnet,
675
594
  ports=ports,
595
+ extra_headers=extra_headers,
676
596
  on_progress=subnet_progress_cb,
677
597
  )
678
598
  results.extend(agents)
679
599
 
680
- if scan_path:
681
- results.extend(scan_files(scan_path))
682
-
683
600
  if do_docker:
684
601
  results.extend(scan_docker())
685
602
 
@@ -34,11 +34,12 @@ _SOURCE_LABEL = {
34
34
  }
35
35
 
36
36
 
37
- def print_subnet_progress(completed: int, total: int, current_ip: str) -> None:
37
+ def print_subnet_progress(completed: int, total: int, current_ip: str, phase: str = "1") -> None:
38
38
  """Inline progress updater for subnet scan — overwrites the same line."""
39
39
  pct = int(completed / total * 100) if total else 0
40
+ label = "TCP sweep" if phase == "1" else "MCP handshake"
40
41
  console.print(
41
- f"\r [dim]Scanning {current_ip} … {pct}% ({completed}/{total})[/dim]",
42
+ f"\r [dim]Phase {phase} {label}: {current_ip} … {pct}% ({completed}/{total})[/dim]",
42
43
  end="",
43
44
  highlight=False,
44
45
  )
@@ -71,7 +72,7 @@ def print_discover_result(
71
72
  f"{subnet_stats.elapsed_seconds:.1f}s[/dim]"
72
73
  )
73
74
  console.print()
74
- console.print(" [dim]Tip: use [bold]--path ./your/code[/bold] to scan source files, "
75
+ console.print(" [dim]Tip: use [bold]--subnet 10.0.0.0/24[/bold] to scan a network range, "
75
76
  "or [bold]--docker[/bold] to inspect containers.[/dim]")
76
77
  console.print()
77
78
  return
@@ -136,6 +137,13 @@ def _print_agent(agent: DiscoveredAgent, verbose: bool) -> None:
136
137
  hosts = ", ".join(sorted(set(agent.live_connections)))
137
138
  console.print(f" {'':>10}[dim]Live connections:[/dim] {hosts}")
138
139
 
140
+ # Enumerated tools (MCP network findings only)
141
+ if agent.tools:
142
+ tools_str = ", ".join(agent.tools[:8])
143
+ if len(agent.tools) > 8:
144
+ tools_str += f" (+{len(agent.tools) - 8} more)"
145
+ console.print(f" {'':>10}[dim]Tools:[/dim] [cyan]{tools_str}[/cyan]")
146
+
139
147
  # Risk reason
140
148
  console.print(f" {'':>10}[dim]{agent.risk_reason}[/dim]")
141
149
 
@@ -190,8 +198,8 @@ def _print_summary(
190
198
  if critical or high:
191
199
  console.print()
192
200
  console.print(
193
- " [dim]Run [bold]sentinel scan <file or --pid or --url>[/bold] "
194
- "for a full posture analysis on any agent above.[/dim]"
201
+ " [dim]Run [bold]sentinel mcp scan <url>[/bold] "
202
+ "for a full tool-by-tool security audit on any MCP server above.[/dim]"
195
203
  )
196
204
 
197
205
  if subnet_stats:
@@ -19,13 +19,18 @@ class AgentFingerprint:
19
19
  system_prompt_snippet: str = ""
20
20
  env_vars: list[str] = dataclasses.field(default_factory=list)
21
21
  external_apis: list[str] = dataclasses.field(default_factory=list)
22
- server_type: str = "agent" # "agent" | "mcp_server"
22
+ server_type: str = "agent" # "agent" | "mcp_server" | "mcp_client"
23
23
 
24
24
 
25
- # MCP server SDK importspresence means this is a tool provider, not an agent
25
+ # Only server-side SDK modulesbare "mcp" and "mcp.types" are shared; "mcp.client.*" is a consumer
26
26
  _MCP_SERVER_IMPORTS = frozenset({
27
- "mcp", "mcp.server", "mcp.server.fastmcp", "mcp.types",
28
- "modelcontextprotocol", "fastmcp",
27
+ "mcp.server", "mcp.server.fastmcp", "fastmcp",
28
+ })
29
+
30
+ # Client-side SDK modules — this file consumes tools from an MCP server
31
+ _MCP_CLIENT_IMPORTS = frozenset({
32
+ "mcp.client", "mcp.client.sse", "mcp.client.stdio",
33
+ "mcp.client.streamable_http", "mcp.client.websocket",
29
34
  })
30
35
 
31
36
  # Ordered by specificity — first match wins
@@ -127,12 +132,23 @@ class _FingerprintVisitor(ast.NodeVisitor):
127
132
  if val and any(val.startswith(p) for p in _KNOWN_MODELS):
128
133
  self.model = val
129
134
 
130
- # os.environ.get("KEY") / os.getenv("KEY")
131
- if func_name in ("get", "getenv") and node.args:
135
+ # os.getenv("KEY") direct function call
136
+ if func_name == "getenv" and node.args:
132
137
  val = _get_str(node.args[0])
133
138
  if val and val not in self.env_vars:
134
139
  self.env_vars.append(val)
135
140
 
141
+ # os.environ.get("KEY") — attribute chain: must be <name>.environ.get(...)
142
+ if func_name == "get" and node.args:
143
+ if (
144
+ isinstance(node.func, ast.Attribute)
145
+ and isinstance(node.func.value, ast.Attribute)
146
+ and node.func.value.attr == "environ"
147
+ ):
148
+ val = _get_str(node.args[0])
149
+ if val and val not in self.env_vars:
150
+ self.env_vars.append(val)
151
+
136
152
  self.generic_visit(node)
137
153
 
138
154
  def visit_Constant(self, node: ast.Constant) -> None:
@@ -210,15 +226,29 @@ def fingerprint_file(path: Path) -> AgentFingerprint:
210
226
 
211
227
  cloud, deployment = _detect_cloud(v.imports, v.has_lambda_handler)
212
228
 
213
- # Determine whether this is an MCP server (tool provider) or an AI agent (tool consumer)
229
+ def _matches(imp: str, prefix: str) -> bool:
230
+ return imp == prefix or imp.startswith(prefix + ".")
231
+
214
232
  is_mcp_server = any(
215
- any(imp == mcp_imp or imp.startswith(mcp_imp + ".") for imp in v.imports)
216
- for mcp_imp in _MCP_SERVER_IMPORTS
233
+ any(_matches(imp, p) for imp in v.imports) for p in _MCP_SERVER_IMPORTS
234
+ )
235
+ is_mcp_client = any(
236
+ any(_matches(imp, p) for imp in v.imports) for p in _MCP_CLIENT_IMPORTS
217
237
  )
218
- server_type = "mcp_server" if is_mcp_server else "agent"
219
238
 
220
- # MCP servers expose their framework label differently
221
- framework = "MCP Server (FastMCP)" if is_mcp_server else _detect_framework(v.imports)
239
+ # A file with mcp.server.* is a tool provider; mcp.client.* is a tool consumer.
240
+ # If both appear the file is unusual (proxy/relay) treat as server since it
241
+ # exposes tools. Client-only → mcp_client, neither → agent.
242
+ if is_mcp_server:
243
+ server_type = "mcp_server"
244
+ has_fastmcp = any(_matches(imp, "mcp.server.fastmcp") or _matches(imp, "fastmcp") for imp in v.imports)
245
+ framework = "FastMCP" if has_fastmcp else "MCP Server"
246
+ elif is_mcp_client:
247
+ server_type = "mcp_client"
248
+ framework = _detect_framework(v.imports)
249
+ else:
250
+ server_type = "agent"
251
+ framework = _detect_framework(v.imports)
222
252
 
223
253
  return AgentFingerprint(
224
254
  framework=framework,
@@ -82,7 +82,7 @@ def _template_summary(
82
82
  ) -> str:
83
83
  """Generate a plain English summary without Claude — used as fallback."""
84
84
  if fingerprint.server_type == "mcp_server":
85
- fw = fingerprint.framework if fingerprint.framework != "unknown" else "MCP server"
85
+ fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "MCP server"
86
86
  parts: list[str] = [f"This is a {fw} — a tool provider with no LLM of its own."]
87
87
  parts.append("It exposes tools for AI agents to call.")
88
88
  if tools:
@@ -90,7 +90,16 @@ def _template_summary(
90
90
  parts.append("Run sentinel mcp scan against the live endpoint for a full security audit.")
91
91
  return " ".join(parts)
92
92
 
93
- fw = fingerprint.framework if fingerprint.framework != "unknown" else "AI agent"
93
+ if fingerprint.server_type == "mcp_client":
94
+ fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "agent"
95
+ model_str = f" using {fingerprint.model}" if fingerprint.model else ""
96
+ parts = [f"This is a {fw} MCP client{model_str}."]
97
+ parts.append("It connects to an external MCP server over the network to access tools.")
98
+ if fingerprint.system_prompt_found:
99
+ parts.append("A system prompt is configured for the LLM.")
100
+ return " ".join(parts)
101
+
102
+ fw = fingerprint.framework if fingerprint.framework not in ("unknown", "") else "AI agent"
94
103
  model_str = f" using {fingerprint.model}" if fingerprint.model else ""
95
104
 
96
105
  parts = []
@@ -55,14 +55,19 @@ def print_inspect_result(report: InspectReport) -> None:
55
55
  console.print()
56
56
  fp = report.fingerprint
57
57
 
58
- type_label = (
59
- "[bold yellow]MCP Server[/bold yellow] (tool provider — use sentinel mcp scan for full audit)"
60
- if fp.server_type == "mcp_server"
61
- else "[bold white]AI Agent[/bold white] (tool consumer with LLM)"
62
- )
58
+ if fp.server_type == "mcp_server":
59
+ type_label = "[bold yellow]MCP Server[/bold yellow] (tool provider — use sentinel mcp scan for full audit)"
60
+ model_fallback = "[dim]n/a — MCP servers have no LLM[/dim]"
61
+ elif fp.server_type == "mcp_client":
62
+ type_label = "[bold cyan]MCP Client[/bold cyan] (agent that connects to an MCP server for tools)"
63
+ model_fallback = "[dim]not detected[/dim]"
64
+ else:
65
+ type_label = "[bold white]AI Agent[/bold white] (standalone LLM agent)"
66
+ model_fallback = "[dim]not detected[/dim]"
67
+
63
68
  _fp_row("Type", type_label)
64
- _fp_row("Framework", fp.framework if fp.framework != "unknown" else "[dim]unknown[/dim]")
65
- _fp_row("Model", fp.model or ("[dim]n/a — MCP servers have no LLM[/dim]" if fp.server_type == "mcp_server" else "[dim]not detected[/dim]"))
69
+ _fp_row("Framework", fp.framework if fp.framework not in ("unknown", "") else "[dim]unknown[/dim]")
70
+ _fp_row("Model", fp.model or model_fallback)
66
71
  _fp_row("Python", fp.python_version or "[dim]not detected[/dim]")
67
72
  _fp_row("Deployment", fp.deployment if fp.deployment != "local" else "[dim]local[/dim]")
68
73
  _fp_row("Cloud", fp.cloud if fp.cloud != "unknown" else "[dim]on-prem / unknown[/dim]")
@@ -108,8 +108,8 @@ def scan_http(
108
108
 
109
109
  if resp.status_code in (401, 403):
110
110
  raise McpAuthRequired(resp.status_code)
111
- if resp.status_code == 405:
112
- # Old SSE transport — fall back automatically
111
+ if resp.status_code in (404, 405):
112
+ # Server may use SSE transport — root path returns 404, /sse returns 405
113
113
  return scan_sse(url, extra_headers=extra_headers, timeout=timeout)
114
114
 
115
115
  content_type = resp.headers.get("content-type", "")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentsentinel-cli"
7
- version = "0.7.1"
7
+ version = "0.7.3"
8
8
  description = "Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"