splitagent 0.0.3__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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,310 @@
1
+ """Data structures shared across the engine, the agents and the reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import asdict, dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Any, ClassVar
9
+
10
+ SEVERITIES = ("critical", "high", "medium", "low", "info")
11
+
12
+ SEVERITY_ORDER = {name: index for index, name in enumerate(SEVERITIES)}
13
+
14
+
15
+ def _now() -> str:
16
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
17
+
18
+
19
+ def new_id(prefix: str) -> str:
20
+ return f"{prefix}-{uuid.uuid4().hex[:8]}"
21
+
22
+
23
+ def normalize_severity(value: str | None, score: float | None = None) -> str:
24
+ if value:
25
+ value = value.strip().lower()
26
+ if value in SEVERITY_ORDER:
27
+ return value
28
+ if score is not None:
29
+ if score >= 9.0:
30
+ return "critical"
31
+ if score >= 7.0:
32
+ return "high"
33
+ if score >= 4.0:
34
+ return "medium"
35
+ if score > 0:
36
+ return "low"
37
+ return "info"
38
+
39
+
40
+ @dataclass
41
+ class Finding:
42
+ """A vulnerability or weakness discovered by the Red Agent."""
43
+
44
+ id: str = field(default_factory=lambda: new_id("F"))
45
+ title: str = ""
46
+ category: str = "general"
47
+ description: str = ""
48
+ severity: str = "info"
49
+ cvss_vector: str = ""
50
+ cvss_score: float = 0.0
51
+ target: str = ""
52
+ endpoint: str = ""
53
+ evidence: str = ""
54
+ recommendation: str = ""
55
+ # Report fields: keep the AI's contribution to a fixed, structured shape so
56
+ # the generated document is consistent from run to run.
57
+ cwe: str = ""
58
+ owasp: str = ""
59
+ impact: str = ""
60
+ reproduction: str = ""
61
+ references: list[str] = field(default_factory=list)
62
+ confidence: str = "medium"
63
+ discovered_by: str = "red"
64
+ round: int = 0
65
+ status: str = "open" # open | mitigated | accepted | false-positive
66
+ created_at: str = field(default_factory=_now)
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return asdict(self)
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: dict[str, Any]) -> Finding:
73
+ if not isinstance(data, dict):
74
+ return cls()
75
+ known = set(cls.__dataclass_fields__)
76
+ filtered = {k: v for k, v in data.items() if k in known}
77
+ # Coerce the fields the report formats numerically; a corrupt session
78
+ # must degrade, not crash the renderer.
79
+ try:
80
+ filtered["cvss_score"] = float(filtered.get("cvss_score") or 0.0)
81
+ except (TypeError, ValueError):
82
+ filtered["cvss_score"] = 0.0
83
+ try:
84
+ filtered["round"] = int(filtered.get("round") or 0)
85
+ except (TypeError, ValueError):
86
+ filtered["round"] = 0
87
+ if not isinstance(filtered.get("references"), list):
88
+ filtered["references"] = []
89
+ return cls(**filtered)
90
+
91
+
92
+ @dataclass
93
+ class Mitigation:
94
+ """A defensive countermeasure produced by the Blue Agent."""
95
+
96
+ id: str = field(default_factory=lambda: new_id("M"))
97
+ finding_id: str = ""
98
+ title: str = ""
99
+ kind: str = "config" # firewall | patch | config | detection | process
100
+ description: str = ""
101
+ content: str = ""
102
+ rationale: str = ""
103
+ status: str = "proposed" # proposed | applied | verified | rejected
104
+ verified: bool = False
105
+ round: int = 0
106
+ created_at: str = field(default_factory=_now)
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ return asdict(self)
110
+
111
+ @classmethod
112
+ def from_dict(cls, data: dict[str, Any]) -> Mitigation:
113
+ if not isinstance(data, dict):
114
+ return cls()
115
+ known = set(cls.__dataclass_fields__)
116
+ filtered = {k: v for k, v in data.items() if k in known}
117
+ try:
118
+ filtered["round"] = int(filtered.get("round") or 0)
119
+ except (TypeError, ValueError):
120
+ filtered["round"] = 0
121
+ return cls(**filtered)
122
+
123
+
124
+ @dataclass
125
+ class Round:
126
+ """A single Red -> Blue cycle."""
127
+
128
+ index: int = 1
129
+ red_summary: str = ""
130
+ blue_summary: str = ""
131
+ finding_ids: list[str] = field(default_factory=list)
132
+ mitigation_ids: list[str] = field(default_factory=list)
133
+ started_at: str = field(default_factory=_now)
134
+ ended_at: str = ""
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ return asdict(self)
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: dict[str, Any]) -> Round:
141
+ if not isinstance(data, dict):
142
+ return cls()
143
+ known = set(cls.__dataclass_fields__)
144
+ filtered = {k: v for k, v in data.items() if k in known}
145
+ try:
146
+ filtered["index"] = int(filtered.get("index") or 1)
147
+ except (TypeError, ValueError):
148
+ filtered["index"] = 1
149
+ if not isinstance(filtered.get("finding_ids"), list):
150
+ filtered["finding_ids"] = []
151
+ if not isinstance(filtered.get("mitigation_ids"), list):
152
+ filtered["mitigation_ids"] = []
153
+ return cls(**filtered)
154
+
155
+
156
+ @dataclass
157
+ class SessionState:
158
+ """The complete, serialisable state of an audit session."""
159
+
160
+ id: str = field(default_factory=lambda: new_id("S"))
161
+ name: str = "splitagent"
162
+ target: str = ""
163
+ target_kind: str = "web"
164
+ scope: list[str] = field(default_factory=list)
165
+ model: str = ""
166
+ provider: str = ""
167
+ started_at: str = field(default_factory=_now)
168
+ ended_at: str = ""
169
+ rounds: list[Round] = field(default_factory=list)
170
+ findings: list[Finding] = field(default_factory=list)
171
+ mitigations: list[Mitigation] = field(default_factory=list)
172
+ notes: list[str] = field(default_factory=list)
173
+ usage: dict[str, int] = field(default_factory=dict)
174
+ checkpoints: list[dict[str, Any]] = field(default_factory=list)
175
+ traces: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
176
+ todos: list[dict[str, str]] = field(default_factory=list)
177
+
178
+ # -- convenience ------------------------------------------------------- #
179
+ def add_finding(self, finding: Finding) -> Finding:
180
+ self.findings.append(finding)
181
+ return finding
182
+
183
+ def add_mitigation(self, mitigation: Mitigation) -> Mitigation:
184
+ self.mitigations.append(mitigation)
185
+ return mitigation
186
+
187
+ def get_finding(self, finding_id: str) -> Finding | None:
188
+ return next((f for f in self.findings if f.id == finding_id), None)
189
+
190
+ def rounds_by_index(self) -> dict[int, Round]:
191
+ return {r.index: r for r in self.rounds}
192
+
193
+ def latest_round(self) -> Round | None:
194
+ return self.rounds[-1] if self.rounds else None
195
+
196
+ def severity_counts(self) -> dict[str, int]:
197
+ counts = dict.fromkeys(SEVERITIES, 0)
198
+ for finding in self.findings:
199
+ counts[normalize_severity(finding.severity, finding.cvss_score)] += 1
200
+ return counts
201
+
202
+ SEVERITY_WEIGHTS: ClassVar[dict[str, float]] = {
203
+ "critical": 10.0,
204
+ "high": 6.0,
205
+ "medium": 3.0,
206
+ "low": 1.0,
207
+ "info": 0.25,
208
+ }
209
+
210
+ def finding_weight(self, finding: Finding) -> float:
211
+ severity = normalize_severity(finding.severity, finding.cvss_score)
212
+ return self.SEVERITY_WEIGHTS.get(severity, 0.25)
213
+
214
+ def resilience_score(self) -> float:
215
+ """0-100: how much of the discovered attack surface is actually closed.
216
+
217
+ Only a mitigation that was *re-tested* counts. Proposing a firewall
218
+ rule changes nothing on the target, so scoring proposals as fixes
219
+ reports a confident number that is simply untrue - which is worse than
220
+ reporting nothing, because the operator stops looking.
221
+ """
222
+ if not self.findings:
223
+ return 100.0
224
+ total = 0.0
225
+ covered = 0.0
226
+ for finding in self.findings:
227
+ weight = self.finding_weight(finding)
228
+ total += weight
229
+ if self.finding_is_closed(finding):
230
+ covered += weight
231
+ if total <= 0:
232
+ return 100.0
233
+ return round((covered / total) * 100.0, 1)
234
+
235
+ def finding_is_closed(self, finding: Finding) -> bool:
236
+ """True only when a verified mitigation addresses this finding."""
237
+ if finding.status in ("false-positive", "accepted"):
238
+ # Not a real issue, or consciously accepted: not an open risk.
239
+ return True
240
+ return any(
241
+ m.finding_id == finding.id and m.verified and m.status == "verified"
242
+ for m in self.mitigations
243
+ )
244
+
245
+ def resilience_breakdown(self) -> dict[str, Any]:
246
+ """The number plus the evidence behind it, for the report and the UI."""
247
+ proposed = sum(1 for m in self.mitigations if not m.verified)
248
+ verified = sum(1 for m in self.mitigations if m.verified)
249
+ open_critical = [
250
+ f.id
251
+ for f in self.findings
252
+ if not self.finding_is_closed(f)
253
+ and normalize_severity(f.severity, f.cvss_score) == "critical"
254
+ ]
255
+ return {
256
+ "score": self.resilience_score(),
257
+ "findings_total": len(self.findings),
258
+ "findings_closed": sum(1 for f in self.findings if self.finding_is_closed(f)),
259
+ "open_critical": open_critical,
260
+ "mitigations_proposed": proposed,
261
+ "mitigations_verified": verified,
262
+ "verified_is_zero": verified == 0 and bool(self.findings),
263
+ }
264
+
265
+ def to_dict(self) -> dict[str, Any]:
266
+ return {
267
+ "id": self.id,
268
+ "name": self.name,
269
+ "target": self.target,
270
+ "target_kind": self.target_kind,
271
+ "scope": list(self.scope),
272
+ "model": self.model,
273
+ "provider": self.provider,
274
+ "started_at": self.started_at,
275
+ "ended_at": self.ended_at,
276
+ "rounds": [r.to_dict() for r in self.rounds],
277
+ "findings": [f.to_dict() for f in self.findings],
278
+ "mitigations": [m.to_dict() for m in self.mitigations],
279
+ "notes": list(self.notes),
280
+ "usage": dict(self.usage),
281
+ "checkpoints": list(self.checkpoints),
282
+ "traces": self.traces,
283
+ "todos": list(self.todos),
284
+ }
285
+
286
+ @classmethod
287
+ def from_dict(cls, data: dict[str, Any]) -> SessionState:
288
+ return cls(
289
+ id=data.get("id", new_id("S")),
290
+ name=data.get("name", "splitagent"),
291
+ target=data.get("target", ""),
292
+ target_kind=data.get("target_kind", "web"),
293
+ scope=list(data.get("scope", [])),
294
+ model=data.get("model", ""),
295
+ provider=data.get("provider", ""),
296
+ started_at=data.get("started_at", _now()),
297
+ ended_at=data.get("ended_at", ""),
298
+ rounds=[Round.from_dict(r) for r in data.get("rounds", []) if isinstance(r, dict)],
299
+ findings=[
300
+ Finding.from_dict(f) for f in data.get("findings", []) if isinstance(f, dict)
301
+ ],
302
+ mitigations=[
303
+ Mitigation.from_dict(m) for m in data.get("mitigations", []) if isinstance(m, dict)
304
+ ],
305
+ notes=list(data.get("notes", [])),
306
+ usage=dict(data.get("usage", {})),
307
+ checkpoints=list(data.get("checkpoints", [])),
308
+ traces=dict(data.get("traces", {})),
309
+ todos=[dict(item) for item in data.get("todos", [])],
310
+ )
@@ -0,0 +1,73 @@
1
+ """Subprocess helpers that never flash a console window on Windows.
2
+
3
+ When SplitAgent runs under ``pythonw.exe`` (the desktop app) there is no
4
+ console attached. Any child process spawned without the right flags makes
5
+ Windows create one, which shows up as a storm of CMD windows spamming the
6
+ screen - especially during a Docker build or an apt install that spawns many
7
+ subprocesses. Every command SplitAgent runs goes through here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import subprocess
14
+ from typing import Any
15
+
16
+ # Windows process-creation flags.
17
+ CREATE_NO_WINDOW = 0x08000000
18
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
19
+ DETACHED_PROCESS = 0x00000008
20
+
21
+ IS_WINDOWS = os.name == "nt"
22
+
23
+
24
+ def hidden_flags(detach: bool = False) -> int:
25
+ """Creation flags that keep a child process invisible and independent."""
26
+ if not IS_WINDOWS:
27
+ return 0
28
+ flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
29
+ if detach:
30
+ flags |= DETACHED_PROCESS
31
+ return flags
32
+
33
+
34
+ def run(
35
+ argv: list[str],
36
+ *,
37
+ cwd: str | None = None,
38
+ timeout: int | None = None,
39
+ env: dict[str, str] | None = None,
40
+ text: bool = False,
41
+ shell: bool = False,
42
+ ) -> subprocess.CompletedProcess[Any]:
43
+ """``subprocess.run`` with the console window suppressed on Windows."""
44
+ return subprocess.run(
45
+ argv,
46
+ cwd=cwd,
47
+ capture_output=True,
48
+ timeout=timeout,
49
+ env=env,
50
+ check=False,
51
+ shell=shell,
52
+ creationflags=hidden_flags(),
53
+ )
54
+
55
+
56
+ def popen(
57
+ argv: list[str],
58
+ *,
59
+ cwd: str | None = None,
60
+ env: dict[str, str] | None = None,
61
+ detach: bool = True,
62
+ ) -> subprocess.Popen[Any]:
63
+ """Launch a background process with no console window."""
64
+ return subprocess.Popen(
65
+ argv,
66
+ cwd=cwd,
67
+ env=env,
68
+ stdout=subprocess.DEVNULL,
69
+ stderr=subprocess.DEVNULL,
70
+ stdin=subprocess.DEVNULL,
71
+ creationflags=hidden_flags(detach=detach),
72
+ close_fds=True,
73
+ )
@@ -0,0 +1,184 @@
1
+ """Ephemeral Docker sandbox management.
2
+
3
+ The sandbox gives the agents a disposable target: a container is started on
4
+ an isolated bridge network, exercised for the duration of the audit and then
5
+ removed. When Docker is unavailable the framework degrades gracefully to
6
+ "local mode" (the agents operate against the configured target only).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import shutil
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from splitagent.config import SandboxConfig
17
+ from splitagent.core import proc
18
+ from splitagent.errors import SandboxError
19
+
20
+ # Ready-to-use intentionally vulnerable targets for demos / training.
21
+ # ``port_map`` is {host_port: container_port}: the publish argument is
22
+ # ``-p host:container`` and the URL is derived from the host port.
23
+ TARGET_PRESETS: dict[str, dict[str, Any]] = {
24
+ "juice-shop": {
25
+ "image": "bkimminich/juice-shop:latest",
26
+ "port_map": {"3000": 3000},
27
+ "url": "http://localhost:3000",
28
+ },
29
+ "dvwa": {
30
+ "image": "vulnerables/web-dvwa:latest",
31
+ "port_map": {"8080": 80},
32
+ "url": "http://localhost:8080",
33
+ },
34
+ "bwapp": {
35
+ "image": "raesene/bwapp:latest",
36
+ "port_map": {"8081": 80},
37
+ "url": "http://localhost:8081",
38
+ },
39
+ "webgoat": {
40
+ "image": "webgoat/webgoat:latest",
41
+ "port_map": {"8082": 8080},
42
+ "url": "http://localhost:8082/WebGoat",
43
+ },
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class SandboxStatus:
49
+ available: bool
50
+ running: bool = False
51
+ container_id: str = ""
52
+ name: str = ""
53
+ url: str = ""
54
+ message: str = ""
55
+
56
+
57
+ class DockerSandbox:
58
+ """Thin wrapper around the ``docker`` CLI."""
59
+
60
+ def __init__(self, config: SandboxConfig, session_id: str = "run") -> None:
61
+ self.config = config
62
+ self.name = f"splitagent-target-{session_id}"
63
+ self.container_id = ""
64
+ self.url = ""
65
+ self.engine = config.engine or "docker"
66
+
67
+ # -- detection --------------------------------------------------------- #
68
+ @staticmethod
69
+ def docker_available() -> bool:
70
+ return shutil.which("docker") is not None
71
+
72
+ async def _run(self, *args: str, check: bool = False) -> tuple[int, str, str]:
73
+ try:
74
+ child = await asyncio.create_subprocess_exec(
75
+ self.engine,
76
+ *args,
77
+ stdout=asyncio.subprocess.PIPE,
78
+ stderr=asyncio.subprocess.PIPE,
79
+ stdin=asyncio.subprocess.DEVNULL,
80
+ # No console window on Windows (the desktop app has none).
81
+ creationflags=proc.hidden_flags(),
82
+ )
83
+ except FileNotFoundError as exc: # pragma: no cover - guarded by detection
84
+ raise SandboxError(
85
+ f"'{self.engine}' was not found on PATH. Disable the sandbox or install Docker."
86
+ ) from exc
87
+ stdout, stderr = await child.communicate()
88
+ code = child.returncode or 0
89
+ out = stdout.decode("utf-8", "replace").strip()
90
+ err = stderr.decode("utf-8", "replace").strip()
91
+ if check and code != 0:
92
+ raise SandboxError(err or out or f"{self.engine} exited with {code}")
93
+ return code, out, err
94
+
95
+ # -- lifecycle --------------------------------------------------------- #
96
+ async def up(self) -> SandboxStatus:
97
+ if not self.config.enabled:
98
+ return SandboxStatus(available=head_ok(), message="sandbox disabled")
99
+ if not self.docker_available():
100
+ return SandboxStatus(
101
+ available=False,
102
+ message="Docker not found; running without sandbox.",
103
+ )
104
+
105
+ await self._ensure_network()
106
+ await self.down(quiet=True)
107
+
108
+ args = [
109
+ "run",
110
+ "-d",
111
+ "--name",
112
+ self.name,
113
+ "--network",
114
+ self.config.network or "splitagent-net",
115
+ ]
116
+ if self.config.auto_remove:
117
+ args.append("--rm")
118
+ for host_port, container_port in (self.config.port_map or {}).items():
119
+ args += ["-p", f"{host_port}:{container_port}"]
120
+ args.append(self.config.image)
121
+
122
+ code, out, err = await self._run(*args)
123
+ if code != 0:
124
+ return SandboxStatus(
125
+ available=True,
126
+ running=False,
127
+ message=err or "failed to start container",
128
+ )
129
+ self.container_id = out.splitlines()[-1].strip()
130
+ self.url = self._infer_url()
131
+ return SandboxStatus(
132
+ available=True,
133
+ running=True,
134
+ container_id=self.container_id,
135
+ name=self.name,
136
+ url=self.url,
137
+ message="sandbox started",
138
+ )
139
+
140
+ async def down(self, quiet: bool = False) -> None:
141
+ if not self.docker_available():
142
+ return
143
+ await self._run("rm", "-f", self.name)
144
+ self.container_id = ""
145
+
146
+ async def status(self) -> SandboxStatus:
147
+ if not self.docker_available():
148
+ return SandboxStatus(available=False, message="Docker not found")
149
+ code, out, _ = await self._run(
150
+ "ps", "--filter", f"name={self.name}", "--format", "{{.ID}} {{.Names}}"
151
+ )
152
+ running = bool(out.strip()) and code == 0
153
+ return SandboxStatus(
154
+ available=True,
155
+ running=running,
156
+ name=self.name,
157
+ message="running" if running else "stopped",
158
+ url=self.url or self._infer_url(),
159
+ )
160
+
161
+ async def logs(self, tail: int = 100) -> str:
162
+ _, out, err = await self._run("logs", "--tail", str(tail), self.name)
163
+ return out or err
164
+
165
+ async def exec(self, *command: str) -> str:
166
+ code, out, err = await self._run("exec", self.name, *command)
167
+ return out if code == 0 else (err or out)
168
+
169
+ async def _ensure_network(self) -> None:
170
+ network = self.config.network or "splitagent-net"
171
+ _code, out, _ = await self._run("network", "ls", "--format", "{{.Name}}")
172
+ if network in out.splitlines():
173
+ return
174
+ await self._run("network", "create", network)
175
+
176
+ def _infer_url(self) -> str:
177
+ if not self.config.port_map:
178
+ return ""
179
+ host_port = next(iter(self.config.port_map))
180
+ return f"http://localhost:{host_port}"
181
+
182
+
183
+ def head_ok() -> bool:
184
+ return True