feat: Expose Caido proxy port to host for human-in-the-loop interaction

Users can now access the Caido web UI from their browser to inspect traffic,
replay requests, and perform manual testing alongside the automated scan.

- Map Caido port (48080) to a random host port in DockerRuntime
- Add caido_port to SandboxInfo and track across container lifecycle
- Display Caido URL in TUI sidebar stats panel with selectable text
- Bind Caido to 0.0.0.0 in entrypoint (requires image rebuild)
- Bump sandbox image to 0.1.12
- Restore discord link in exit screen
This commit is contained in:
0xallam
2026-02-23 18:24:29 -08:00
committed by Ahmed Allam
parent 0ca9af3b3e
commit d84d72d986
12 changed files with 54 additions and 19 deletions

View File

@@ -9,7 +9,7 @@ if [ ! -f /app/certs/ca.p12 ]; then
exit 1
fi
caido-cli --listen 127.0.0.1:${CAIDO_PORT} \
caido-cli --listen 0.0.0.0:${CAIDO_PORT} \
--allow-guests \
--no-logging \
--no-open \

View File

@@ -51,7 +51,7 @@ Configure Strix using environment variables or a config file.
## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:0.1.11" type="string">
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:0.1.12" type="string">
Docker image to use for the sandbox container.
</ParamField>

View File

@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix
REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.11"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:0.1.12"
MUTED='\033[0;2m'
RED='\033[0;31m'

View File

@@ -333,6 +333,14 @@ class BaseAgent(metaclass=AgentMeta):
if "agent_id" in sandbox_info:
self.state.sandbox_info["agent_id"] = sandbox_info["agent_id"]
caido_port = sandbox_info.get("caido_port")
if caido_port:
from strix.telemetry.tracer import get_global_tracer
tracer = get_global_tracer()
if tracer:
tracer.caido_url = f"localhost:{caido_port}"
except Exception as e:
from strix.telemetry import posthog

View File

@@ -40,7 +40,7 @@ class Config:
strix_disable_browser = "false"
# Runtime Configuration
strix_image = "ghcr.io/usestrix/strix-sandbox:0.1.11"
strix_image = "ghcr.io/usestrix/strix-sandbox:0.1.12"
strix_runtime_backend = "docker"
strix_sandbox_execution_timeout = "120"
strix_sandbox_connect_timeout = "10"

View File

@@ -77,12 +77,21 @@ Toast.-information .toast--title {
margin-bottom: 0;
}
#stats_display {
#stats_scroll {
height: auto;
max-height: 15;
background: transparent;
padding: 0;
margin: 0;
border: round #333333;
scrollbar-size: 0 0;
}
#stats_display {
height: auto;
background: transparent;
padding: 0 1;
margin: 0;
}
#vulnerabilities_panel {

View File

@@ -462,7 +462,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
console.print("\n")
console.print(panel)
console.print()
console.print("[#60a5fa]models.strix.ai[/]")
console.print("[#60a5fa]models.strix.ai[/] [dim]·[/] [#60a5fa]discord.gg/strix-ai[/]")
console.print()

View File

@@ -829,11 +829,11 @@ class StrixTUIApp(App): # type: ignore[misc]
agents_tree.guide_style = "dashed"
stats_display = Static("", id="stats_display")
stats_display.ALLOW_SELECT = False
stats_scroll = VerticalScroll(stats_display, id="stats_scroll")
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_display, id="sidebar")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
content_container.mount(chat_area_container)
content_container.mount(sidebar)
@@ -1272,6 +1272,9 @@ class StrixTUIApp(App): # type: ignore[misc]
if not self._is_widget_safe(stats_display):
return
if self.screen.selections:
return
stats_content = Text()
stats_text = build_tui_stats_text(self.tracer, self.agent_config)
@@ -1281,15 +1284,7 @@ class StrixTUIApp(App): # type: ignore[misc]
version = get_package_version()
stats_content.append(f"\nv{version}", style="white")
from rich.panel import Panel
stats_panel = Panel(
stats_content,
border_style="#333333",
padding=(0, 1),
)
self._safe_widget_operation(stats_display.update, stats_panel)
self._safe_widget_operation(stats_display.update, stats_content)
def _update_vulnerabilities_panel(self) -> None:
"""Update the vulnerabilities panel with current vulnerability data."""

View File

@@ -390,6 +390,12 @@ def build_tui_stats_text(tracer: Any, agent_config: dict[str, Any] | None = None
stats_text.append(" · ", style="white")
stats_text.append(f"${total_stats['cost']:.2f}", style="white")
caido_url = getattr(tracer, "caido_url", None)
if caido_url:
stats_text.append("\n")
stats_text.append("Caido: ", style="bold white")
stats_text.append(caido_url, style="white")
return stats_text

View File

@@ -22,6 +22,7 @@ from .runtime import AbstractRuntime, SandboxInfo
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
DOCKER_TIMEOUT = 60
CONTAINER_TOOL_SERVER_PORT = 48081
CONTAINER_CAIDO_PORT = 48080
class DockerRuntime(AbstractRuntime):
@@ -37,6 +38,7 @@ class DockerRuntime(AbstractRuntime):
self._scan_container: Container | None = None
self._tool_server_port: int | None = None
self._tool_server_token: str | None = None
self._caido_port: int | None = None
def _find_available_port(self) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -78,6 +80,10 @@ class DockerRuntime(AbstractRuntime):
if port_bindings.get(port_key):
self._tool_server_port = int(port_bindings[port_key][0]["HostPort"])
caido_port_key = f"{CONTAINER_CAIDO_PORT}/tcp"
if port_bindings.get(caido_port_key):
self._caido_port = int(port_bindings[caido_port_key][0]["HostPort"])
def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:
host = self._resolve_docker_host()
health_url = f"http://{host}:{self._tool_server_port}/health"
@@ -121,6 +127,7 @@ class DockerRuntime(AbstractRuntime):
time.sleep(1)
self._tool_server_port = self._find_available_port()
self._caido_port = self._find_available_port()
self._tool_server_token = secrets.token_urlsafe(32)
execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120"
@@ -130,7 +137,10 @@ class DockerRuntime(AbstractRuntime):
detach=True,
name=container_name,
hostname=container_name,
ports={f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port},
ports={
f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port,
f"{CONTAINER_CAIDO_PORT}/tcp": self._caido_port,
},
cap_add=["NET_ADMIN", "NET_RAW"],
labels={"strix-scan-id": scan_id},
environment={
@@ -152,6 +162,7 @@ class DockerRuntime(AbstractRuntime):
if attempt < max_retries:
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
time.sleep(2**attempt)
else:
return container
@@ -173,6 +184,7 @@ class DockerRuntime(AbstractRuntime):
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
try:
container = self.client.containers.get(container_name)
@@ -260,7 +272,7 @@ class DockerRuntime(AbstractRuntime):
raise RuntimeError("Docker container ID is unexpectedly None")
token = existing_token or self._tool_server_token
if self._tool_server_port is None or token is None:
if self._tool_server_port is None or self._caido_port is None or token is None:
raise RuntimeError("Tool server not initialized")
host = self._resolve_docker_host()
@@ -273,6 +285,7 @@ class DockerRuntime(AbstractRuntime):
"api_url": api_url,
"auth_token": token,
"tool_server_port": self._tool_server_port,
"caido_port": self._caido_port,
"agent_id": agent_id,
}
@@ -314,6 +327,7 @@ class DockerRuntime(AbstractRuntime):
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
except (NotFound, DockerException):
pass
@@ -323,6 +337,7 @@ class DockerRuntime(AbstractRuntime):
self._scan_container = None
self._tool_server_port = None
self._tool_server_token = None
self._caido_port = None
if container_name is None:
return

View File

@@ -7,6 +7,7 @@ class SandboxInfo(TypedDict):
api_url: str
auth_token: str | None
tool_server_port: int
caido_port: int
agent_id: str

View File

@@ -56,6 +56,7 @@ class Tracer:
self._next_message_id = 1
self._saved_vuln_ids: set[str] = set()
self.caido_url: str | None = None
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
def set_run_name(self, run_name: str) -> None: