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,691 @@
1
+ """
2
+ sentinel discover — finds AI agents across processes, network, files, and Docker containers.
3
+
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
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import ipaddress
16
+ import json
17
+ import socket
18
+ import subprocess
19
+ from collections.abc import Callable
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from agentsentinel_cli.frameworks import (
25
+ LLM_API_HOSTS,
26
+ LLM_ENV_VARS,
27
+ detect_framework,
28
+ detect_model,
29
+ detect_provider_from_env,
30
+ extract_api_keys,
31
+ )
32
+
33
+ # ── Data model ────────────────────────────────────────────────────────────────
34
+
35
+ @dataclasses.dataclass
36
+ class DiscoveredAgent:
37
+ source: str # process | network | subnet | file | docker
38
+ name: str # human-readable name
39
+ framework: str # LangChain | OpenAI Agents SDK | MCP | etc.
40
+ provider: str # Anthropic | OpenAI | Google | etc.
41
+ 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
43
+ api_keys: list[str] # masked keys: ANTHROPIC_API_KEY=sk-ant-...5f3d
44
+ live_connections: list[str] # LLM API hosts this process is talking to
45
+ risk: str # CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN
46
+ risk_reason: str # one-line explanation of the risk level
47
+ next_step: str # suggested follow-up command
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class SubnetScanStats:
52
+ cidr: str
53
+ total_hosts: int
54
+ hosts_scanned: int
55
+ open_ports_found: int
56
+ agents_found: int
57
+ elapsed_seconds: float
58
+
59
+
60
+ # ── Default port list ─────────────────────────────────────────────────────────
61
+
62
+ _DEFAULT_PORTS = [
63
+ 3000, 3001, 4000, 5000,
64
+ 7860, # Gradio
65
+ 8000, 8001, 8002, 8003,
66
+ 8080, 8443, 8888,
67
+ 9000, 9001,
68
+ 11434, # Ollama
69
+ ]
70
+
71
+ _MCP_INDICATOR_PATHS = ["/sse", "/messages/"]
72
+ _OPENAI_COMPAT_PATHS = ["/v1/models"]
73
+ _SENTINEL_PATHS = ["/api/v1/agents", "/health"]
74
+
75
+ # Subnet scan limits — scanning beyond these sizes is impractical
76
+ _MAX_SUBNET_HOSTS_WARN = 1024 # /22 — warn but allow
77
+ _MAX_SUBNET_HOSTS_BLOCK = 65536 # /16 — refuse (too slow, too noisy)
78
+
79
+
80
+ # ── Process scanner ───────────────────────────────────────────────────────────
81
+
82
+ _PROCESS_SKIP_FRAGMENTS = frozenset({
83
+ "lsp-server", "lsp-runner", "lsp-worker",
84
+ "server.bundle",
85
+ "esbuild", "webpack", "rollup", "vite",
86
+ "typescript-language-server", "tsserver",
87
+ "pylsp", "pyright",
88
+ "gopls", "rust-analyzer", "clangd",
89
+ "code helper", "vmware fusion", "docker desktop",
90
+ "safari", "firefox", "chrome helper",
91
+ })
92
+
93
+ _AGENT_CMDLINE_SIGNALS = frozenset({
94
+ "langchain", "crewai", "autogen", "pyautogen",
95
+ "openai.agents", "agents_sdk",
96
+ "mcp_shim", "mcp-shim", "mcp.server",
97
+ "sentinel_middleware", "agentsentinel",
98
+ "llama_index", "llamaindex",
99
+ "haystack", "pydantic_ai", "semantic_kernel",
100
+ "agent.py", "agents.py",
101
+ })
102
+
103
+
104
+ def scan_processes() -> list[DiscoveredAgent]:
105
+ """Scan running processes for AI agents using psutil."""
106
+ try:
107
+ import psutil
108
+ except ImportError:
109
+ return []
110
+
111
+ found: list[DiscoveredAgent] = []
112
+ seen: set[tuple[str, str, str]] = set()
113
+
114
+ for proc in psutil.process_iter(["pid", "name", "cmdline", "status"]):
115
+ try:
116
+ info = proc.as_dict(attrs=["pid", "name", "cmdline", "status"])
117
+ cmdline = info.get("cmdline") or []
118
+ proc_name = (info.get("name") or "").lower()
119
+
120
+ if not cmdline:
121
+ continue
122
+
123
+ if any(skip in proc_name for skip in _PROCESS_SKIP_FRAGMENTS):
124
+ continue
125
+
126
+ cmd_str = " ".join(str(c) for c in cmdline).lower()
127
+
128
+ if not any(p in cmd_str for p in ("python", "node", "npx", "deno")):
129
+ continue
130
+
131
+ has_framework_in_cmd = any(s in cmd_str for s in _AGENT_CMDLINE_SIGNALS)
132
+
133
+ live_connections: list[str] = []
134
+ try:
135
+ for conn in proc.connections(kind="tcp"):
136
+ if conn.raddr and conn.raddr.ip:
137
+ try:
138
+ host = socket.gethostbyaddr(conn.raddr.ip)[0]
139
+ if any(h in host for h in LLM_API_HOSTS):
140
+ live_connections.append(host)
141
+ except (socket.herror, socket.gaierror):
142
+ pass
143
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
144
+ pass
145
+
146
+ if not has_framework_in_cmd and not live_connections:
147
+ continue
148
+
149
+ try:
150
+ env = proc.environ()
151
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
152
+ env = {}
153
+
154
+ framework, provider = detect_framework(cmd_str)
155
+ if not provider:
156
+ provider = detect_provider_from_env(env)
157
+
158
+ api_keys = extract_api_keys(env)
159
+ model = detect_model(cmd_str) or detect_model(" ".join(env.values()))
160
+ name = _name_from_cmdline(cmdline)
161
+
162
+ dedup_key = (name, framework, api_keys[0] if api_keys else "")
163
+ if dedup_key in seen:
164
+ continue
165
+ seen.add(dedup_key)
166
+
167
+ risk, risk_reason = _assess_process_risk(api_keys, live_connections, framework)
168
+
169
+ found.append(DiscoveredAgent(
170
+ source="process",
171
+ name=name,
172
+ framework=framework,
173
+ provider=provider,
174
+ model=model,
175
+ location=f"pid:{info['pid']}",
176
+ api_keys=api_keys,
177
+ live_connections=live_connections,
178
+ risk=risk,
179
+ risk_reason=risk_reason,
180
+ next_step=f"sentinel scan --pid {info['pid']}",
181
+ ))
182
+
183
+ except (psutil.NoSuchProcess, psutil.ZombieProcess):
184
+ continue
185
+
186
+ return found
187
+
188
+
189
+ def _name_from_cmdline(cmdline: list[str]) -> str:
190
+ for part in reversed(cmdline):
191
+ p = Path(part)
192
+ if p.suffix in (".py", ".js", ".ts") and p.stem not in ("__main__", "-c"):
193
+ return p.stem.replace("_", "-")
194
+ for part in cmdline:
195
+ if part and not part.startswith("-"):
196
+ return Path(part).stem or part
197
+ return "unknown-agent"
198
+
199
+
200
+ def _assess_process_risk(
201
+ api_keys: list[str],
202
+ live_connections: list[str],
203
+ framework: str,
204
+ ) -> tuple[str, str]:
205
+ if api_keys:
206
+ return (
207
+ "CRITICAL",
208
+ f"LLM API key{'s' if len(api_keys) > 1 else ''} exposed in process environment "
209
+ f"({', '.join(k.split('=')[0] for k in api_keys)})",
210
+ )
211
+ if live_connections:
212
+ return "HIGH", f"Active connection to LLM API: {', '.join(set(live_connections))}"
213
+ if framework != "Unknown":
214
+ return "MEDIUM", f"{framework} agent detected — run 'sentinel scan' for full analysis"
215
+ return "UNKNOWN", "AI-related process detected — framework not identified"
216
+
217
+
218
+ # ── Network scanner (single host) ─────────────────────────────────────────────
219
+
220
+ def scan_network(
221
+ host: str = "127.0.0.1",
222
+ ports: list[int] | None = None,
223
+ timeout: float = 0.5,
224
+ ) -> list[DiscoveredAgent]:
225
+ """Probe a single host's ports for AI agent endpoints."""
226
+ if ports is None:
227
+ ports = _DEFAULT_PORTS
228
+
229
+ open_ports = _find_open_ports(host, ports, timeout)
230
+ if not open_ports:
231
+ return []
232
+
233
+ found: list[DiscoveredAgent] = []
234
+ for port in open_ports:
235
+ agent = _probe_port(host, port, timeout * 4)
236
+ if agent:
237
+ found.append(agent)
238
+ return found
239
+
240
+
241
+ def _find_open_ports(host: str, ports: list[int], timeout: float) -> list[int]:
242
+ """Fast parallel TCP connect to find open ports on a single host."""
243
+ open_ports: list[int] = []
244
+
245
+ def check(port: int) -> int | None:
246
+ try:
247
+ with socket.create_connection((host, port), timeout=timeout):
248
+ return port
249
+ except (OSError, ConnectionRefusedError):
250
+ return None
251
+
252
+ with ThreadPoolExecutor(max_workers=min(50, len(ports))) as pool:
253
+ for result in as_completed({pool.submit(check, p): p for p in ports}):
254
+ r = result.result()
255
+ if r is not None:
256
+ open_ports.append(r)
257
+
258
+ return sorted(open_ports)
259
+
260
+
261
+ # ── Subnet scanner (CIDR range) ───────────────────────────────────────────────
262
+
263
+ def enumerate_hosts(cidr: str) -> list[str]:
264
+ """Expand a CIDR string into a list of usable host IP strings."""
265
+ network = ipaddress.ip_network(cidr, strict=False)
266
+ # For /31 and /32, include network address too (point-to-point / single host)
267
+ if network.prefixlen >= 31:
268
+ return [str(ip) for ip in network]
269
+ return [str(ip) for ip in network.hosts()]
270
+
271
+
272
+ def scan_subnet(
273
+ cidr: str,
274
+ ports: list[int] | None = None,
275
+ timeout: float = 0.3,
276
+ on_progress: Optional[Callable[[int, int, str], None]] = None,
277
+ ) -> tuple[list[DiscoveredAgent], SubnetScanStats]:
278
+ """Scan every host in a CIDR subnet for AI agent endpoints.
279
+
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)
283
+
284
+ 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
289
+
290
+ Returns:
291
+ (agents, stats) tuple
292
+ """
293
+ import time
294
+
295
+ if ports is None:
296
+ ports = _DEFAULT_PORTS
297
+
298
+ hosts = enumerate_hosts(cidr)
299
+ if not hosts:
300
+ raise ValueError(f"No usable hosts in {cidr}")
301
+
302
+ if len(hosts) > _MAX_SUBNET_HOSTS_BLOCK:
303
+ raise ValueError(
304
+ f"{cidr} contains {len(hosts):,} hosts. "
305
+ f"Maximum supported is {_MAX_SUBNET_HOSTS_BLOCK:,} (/{32 - _MAX_SUBNET_HOSTS_BLOCK.bit_length() + 1}). "
306
+ "Use a smaller subnet or scan individual host ranges."
307
+ )
308
+
309
+ started_at = time.monotonic()
310
+ total_probes = len(hosts) * len(ports)
311
+ open_targets: list[tuple[str, int]] = []
312
+
313
+ # ── Phase 1: parallel TCP connect across all host:port pairs ─────────────
314
+ # High concurrency — most connections refuse immediately, failures are cheap.
315
+ completed = 0
316
+ workers = min(250, total_probes)
317
+
318
+ def check_port(host: str, port: int) -> tuple[str, int] | None:
319
+ try:
320
+ with socket.create_connection((host, port), timeout=timeout):
321
+ return (host, port)
322
+ except (OSError, ConnectionRefusedError, socket.timeout):
323
+ return None
324
+
325
+ tasks = [(h, p) for h in hosts for p in ports]
326
+ futures = {}
327
+
328
+ 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):
331
+ result = future.result()
332
+ if result:
333
+ open_targets.append(result)
334
+ completed += 1
335
+ if on_progress:
336
+ host_ip, _ = futures[future]
337
+ on_progress(completed, total_probes, host_ip)
338
+
339
+ # ── Phase 2: HTTP probe on open ports to identify agent type ─────────────
340
+ 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)
345
+
346
+ elapsed = time.monotonic() - started_at
347
+ stats = SubnetScanStats(
348
+ cidr=cidr,
349
+ total_hosts=len(hosts),
350
+ hosts_scanned=len(hosts),
351
+ open_ports_found=len(open_targets),
352
+ agents_found=len(found),
353
+ elapsed_seconds=elapsed,
354
+ )
355
+ return found, stats
356
+
357
+
358
+ # ── Port prober ───────────────────────────────────────────────────────────────
359
+
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
366
+
367
+ base = f"http://{host}:{port}"
368
+ location = f"{host}:{port}"
369
+
370
+ with httpx.Client(timeout=timeout, follow_redirects=True) 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
+ 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],
518
+ 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"
534
+
535
+
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"
551
+
552
+
553
+ # ── Docker scanner ────────────────────────────────────────────────────────────
554
+
555
+ def scan_docker() -> list[DiscoveredAgent]:
556
+ """Find running Docker containers that look like AI agents."""
557
+ if not _docker_available():
558
+ return []
559
+
560
+ try:
561
+ result = subprocess.run(
562
+ ["docker", "ps", "--format", "{{json .}}"],
563
+ capture_output=True, text=True, timeout=10,
564
+ )
565
+ if result.returncode != 0:
566
+ return []
567
+ except (subprocess.TimeoutExpired, FileNotFoundError):
568
+ return []
569
+
570
+ found: list[DiscoveredAgent] = []
571
+
572
+ for line in result.stdout.strip().splitlines():
573
+ if not line.strip():
574
+ continue
575
+ try:
576
+ container = json.loads(line)
577
+ container_id = container.get("ID", "")
578
+ container_name = container.get("Names", "unknown").lstrip("/")
579
+ image = container.get("Image", "")
580
+
581
+ env = _docker_inspect_env(container_id)
582
+ if not env:
583
+ continue
584
+
585
+ has_llm_env = any(var in env for var in LLM_ENV_VARS)
586
+ if not has_llm_env:
587
+ framework, provider = detect_framework(image)
588
+ if framework == "Unknown":
589
+ continue
590
+ else:
591
+ full_text = " ".join([image, container_name, " ".join(env.values())])
592
+ framework, provider = detect_framework(full_text)
593
+
594
+ api_keys = extract_api_keys(env)
595
+ model = detect_model(" ".join(env.values()))
596
+ provider = provider or detect_provider_from_env(env)
597
+ risk, risk_reason = _assess_process_risk(api_keys, [], framework)
598
+
599
+ found.append(DiscoveredAgent(
600
+ source="docker",
601
+ name=container_name,
602
+ framework=framework,
603
+ provider=provider,
604
+ model=model,
605
+ location=f"container:{container_name}",
606
+ api_keys=api_keys,
607
+ live_connections=[],
608
+ risk=risk,
609
+ risk_reason=risk_reason,
610
+ next_step=f"docker exec {container_name} sentinel scan .",
611
+ ))
612
+
613
+ except (json.JSONDecodeError, KeyError):
614
+ continue
615
+
616
+ return found
617
+
618
+
619
+ def _docker_available() -> bool:
620
+ try:
621
+ result = subprocess.run(["docker", "info"], capture_output=True, timeout=5)
622
+ return result.returncode == 0
623
+ except (FileNotFoundError, subprocess.TimeoutExpired):
624
+ return False
625
+
626
+
627
+ def _docker_inspect_env(container_id: str) -> dict[str, str]:
628
+ try:
629
+ result = subprocess.run(
630
+ ["docker", "inspect", "--format",
631
+ "{{range .Config.Env}}{{.}}\n{{end}}", container_id],
632
+ capture_output=True, text=True, timeout=10,
633
+ )
634
+ env: dict[str, str] = {}
635
+ for line in result.stdout.strip().splitlines():
636
+ if "=" in line:
637
+ key, _, value = line.partition("=")
638
+ env[key.strip()] = value.strip()
639
+ return env
640
+ except (subprocess.TimeoutExpired, FileNotFoundError):
641
+ return {}
642
+
643
+
644
+ # ── Orchestrator ──────────────────────────────────────────────────────────────
645
+
646
+ def run_discovery(
647
+ do_process: bool = True,
648
+ do_network: bool = True,
649
+ do_docker: bool = False,
650
+ scan_path: Optional[Path] = None,
651
+ ports: list[int] | None = None,
652
+ subnet: Optional[str] = None,
653
+ subnet_progress_cb: Optional[Callable[[int, int, str], None]] = None,
654
+ ) -> tuple[list[DiscoveredAgent], Optional[SubnetScanStats]]:
655
+ """Run all requested discovery scanners.
656
+
657
+ Returns (agents, subnet_stats). subnet_stats is None when no subnet scan ran.
658
+ """
659
+ results: list[DiscoveredAgent] = []
660
+ subnet_stats: Optional[SubnetScanStats] = None
661
+
662
+ if do_process:
663
+ results.extend(scan_processes())
664
+
665
+ if do_network:
666
+ results.extend(scan_network(ports=ports))
667
+
668
+ if subnet:
669
+ agents, subnet_stats = scan_subnet(
670
+ cidr=subnet,
671
+ ports=ports,
672
+ on_progress=subnet_progress_cb,
673
+ )
674
+ results.extend(agents)
675
+
676
+ if scan_path:
677
+ results.extend(scan_files(scan_path))
678
+
679
+ if do_docker:
680
+ results.extend(scan_docker())
681
+
682
+ return results, subnet_stats
683
+
684
+
685
+ # ── JSON serialisation ────────────────────────────────────────────────────────
686
+
687
+ def as_json(agents: list[DiscoveredAgent]) -> str:
688
+ return json.dumps(
689
+ [dataclasses.asdict(a) for a in agents],
690
+ indent=2,
691
+ )