Skip to content
BreachPilot

BreachPilot WebUI API — v1 Reference

The local WebUI API daemon (--demon / --daemon / --web) exposes a versioned, loopback-only REST + WebSocket API so the bundled WebUI (or third-party clients) can drive assessments, answer decisions, stream live events, and invoke MCP tools through a policy-gated gateway — all through the same AssessmentService the CLI uses.

  • Base URL: http://127.0.0.1:8765 (default; --api-port to change)
  • API version: v1 (all routes under /api/v1)
  • Spec endpoints: GET /docs (Swagger UI), GET /openapi.json
  • Auth: Bearer token on every route except GET /health
  • Transport: HTTP/1.1 + WebSocket; loopback-only bind (no public override in v1)
  • Concurrency: api.max_concurrent_runs (lab default 3, legacy 1 → HTTP 409 on a second; per-run allowlist snapshot so Run A's target never leaks into Run B's allowlist — tools/api/run_manager.py:22, config.yaml:399)
  • Persistence: reports/api_runtime.db (SQLite; Flow B's research.db untouched)
  • Bundled WebUI: python main.py --web builds webui/dist/ (if needed), sets api.serve_webui: true in memory, and serves the SPA at / with a deep-link fallback. The SPA is a Vite + React + TypeScript app under webui/.

Source: app.py (ASGI factory), tools/api/ (services + routes), tools/run_service/ (transport-neutral contracts).


Table of Contents

  1. Security Model
  2. Error Shape
  3. Run Lifecycle
  4. Auth
  5. System Routes
  6. Graph Explorer Routes
  7. Run Routes
  8. Decision Routes
  9. Event Routes
  10. Connections Routes
  11. Data Models
  12. Event Types
  13. Config Reference
  14. Persistence Schema
  15. End-to-End Flow

Security Model

v1 is locked down by design. There is no public-bind path.

LayerEnforcement
Bindassert_api_loopback refuses any host not in {127.0.0.1, localhost, ::1}. --api-host is validated in main._run_daemon and re-validated in create_app.
HTTP authBearerAuth (FastAPI dependency) on every route except GET /health. Constant-time hmac.compare_digest comparison. Missing/invalid/malformed → 401.
Token256-bit URL-safe, generated into api.token_file (default .webui_secret_key, gitignored) on first boot, or overridden via BREACHPILOT_API_TOKEN env. File perms 0o600 where supported. Never logged, never returned by any endpoint.
CORSCORSMiddleware allows only loopback + api.allowed_origins. Credentials enabled. allowed_origins entries must be loopback HTTP(S) or the factory raises.
WebSocket originis_loopback_origin rejects null, non-loopback, schemes other than http/https, userinfo, query, fragment, non-root paths, out-of-range ports. Close 4403 on failure.
WebSocket authFirst message after accept must be {"auth": "<token>"}. Close 4401 on missing/invalid auth (5s timeout).
Config patchPATCH /config re-validates api.allowed_origins is loopback-only before writing.
SecretsGET /secrets returns only configured/missing status per provider key — values are write-only.
Redactionsanitize() recursively redacts any dict value whose key matches `password

WebSocket close codes

CodeMeaning
1011Server not configured / event stream failed
4400Invalid event cursor (after not a non-negative int)
4401Missing/invalid auth message
4403Origin not allowed
4404Run not found

Error Shape

Every error response uses the same envelope. A request_id (UUID) is injected per request by middleware and echoed in the X-Request-ID response header so logs and client-side debugging share a correlation key.

{
  "error": {
    "code": "conflict",
    "message": "A run is already active. Cancel it first.",
    "details": {},
    "request_id": "9b3f1c2a-..."
  }
}
HandlerCodeHTTPWhen
HTTPExceptionhttp_error(from exc)FastAPI HTTP errors
RequestValidationErrorvalidation_error422Pydantic body validation
APIError(from exc)(from exc)Explicit APIError raises in services
ValueErrorvalue_error400ValueError bubbled to route
Exceptioninternal_error500Unhandled (message is generic)

Common APIError codes used by the run manager:

CodeHTTPMeaning
conflict409A run is already active / execution already started
not_found404Run or decision not found
invalid_confirmation400Confirmation text did not match required_confirmation_text
decision_not_found404Decision not found or already answered
no_decisions400No decision broker for this run
no_session409MCP session is not open
no_policy409Exploit policy not available
tool_not_found404Unknown MCP tool name
tool_denied403Exploit policy denied the tool call
tool_error500MCP call_tool raised
cancel_timeout504Run cancellation timed out
invalid_body400Request body shape wrong
invalid_secrets400Secret names not configured / values empty
config_invalid400Config validation failed

Run Lifecycle

A run moves through these states (tools/run_service/models.py:RunState):

draft ──POST /runs──▶ awaiting_confirmation ──answer start_confirm──▶ queued ──▶ running
                                       │                                  │
                                       │                                  ├─▶ awaiting_input ──answer decision──▶ running
                                       │                                  │
                                       └─▶ (yes=true) ──▶ queued ──────────┤
                                                                          │
                          cancelled ◀──cancel── running/cancelling         │
                            │                                              │
                            ├──▶ completed                                 │
                            ├──▶ failed                                    │
                            └──▶ interrupted (daemon restarted mid-run)──┘
StateMeaning
draftRow created, not yet confirmed
awaiting_confirmationPreview ready, waiting on start_confirm decision
queuedConfirmed, waiting for execution slot
runningExecution in progress
awaiting_inputBlocked on a tool_approval / goal_select decision
cancellingCancel requested, tearing down
completedFinished successfully
failedFinished with error
cancelledCancelled by operator
interruptedDaemon restarted while run was live

On daemon startup, persistence.recover_interrupted() marks any run in a live state (draft/awaiting_confirmation/running/awaiting_input/queued/cancelling) as interrupted and expires its pending decisions.

The concurrency invariant is enforced by RunManager (tools/api/run_manager.py:22): when api.max_concurrent_runs concurrent runs are live, the next POST /runs returns 409 conflict. Lab default is 3; legacy single-run behavior is max_concurrent_runs: 1 (then cancel the active run first). Each concurrent run carries its own allowlist snapshot.


Auth

Bearer Token Auth

Every protected route uses Authorization: Bearer <token>. The token is loaded by load_or_create_token:

  1. BREACHPILOT_API_TOKEN env var (if set, non-empty) — takes precedence.
  2. api.token_file (default .webui_secret_key) — read if it exists.
  3. Otherwise generate secrets.token_urlsafe(32) (256-bit), write to token_file with 0o600 perms (best-effort on Windows).

Invalid/missing bearer → 401:

{"detail": "Missing or invalid Authorization header. Expected: Bearer <token>"}
{"detail": "Invalid bearer token."}

WebSocket Auth

WebSocket auth is a two-step handshake (authenticate_websocket):

  1. Origin check (before accept): origin must pass is_loopback_origin. Fail → close 4403.
  2. Auth message (after accept, 5s timeout): first JSON message must be {"auth": "<token>", "after": <int>}.
    • auth compared with hmac.compare_digest. Fail → close 4401.
    • after is the replay cursor (events with sequence > after). Must be a non-negative int (not bool). Fail → close 4400.

A browser disconnect does not cancel the run — the in-memory ring buffer holds recent events for reconnect.


System Routes

Source: tools/api/routes/system.py. Prefix /api/v1, tag system.

GET /health

Auth: none.

Liveness probe.

Response: 200

{"version": "v1", "ready": true}

GET /capabilities

Auth: bearer.

API features, supported run options, constraints, and tool groups.

Response: 200

{
  "api_version": "v1",
  "features": ["runs", "decisions", "events", "websocket", "tool_gateway", "config", "secrets"],
  "constraints": {
    "max_concurrent_runs": 3,
    "loopback_only": true,
    "manual_tool_calls": true
  },
  "run_options": {
    "modes": ["recon", "attack"],
    "kinds": ["agent", "manual"],
    "flags": ["swarm", "parallel_swarm", "critic", "reflection", "adaptive_exploits",
              "long_session", "multi_model_consult", "ultrathink", "recon_first"]
  }
}

GET /config

Auth: bearer.

Return the current configuration, redacted by sanitize() (any key matching the secret pattern is replaced with "[REDACTED]").

Response: 200 — the redacted config dict.


PATCH /config

Auth: bearer.

Apply config changes atomically. Body is a partial config merged recursively into the current config (_merge_config), then validated.

Validation:

  • api.allowed_origins (if present) must be a list of loopback HTTP(S) origins. Non-loopback → 400 config_invalid.
  • The merged config is run through ConfigValidator. Invalid → 400 config_invalid with details.errors.

Write: atomic via temp file + os.replace.

Response: 200

{"status": "ok", "config": { /* redacted merged config */ }}

Errors: 400 invalid_body (not a JSON object), 400 config_invalid.


GET /secrets

Auth: bearer.

Expose only configured/missing provider-key status. Secret values are write-only — this endpoint never returns them.

Response: 200

{
  "keys": {
    "OPENAI_API_KEY": "configured",
    "SERPAPI_API_KEY": "missing"
  }
}

Status is "configured" if the key is in the loaded key file or in the process env, else "missing". Provider key env names come from configured_api_key_env_names(config).


PUT /secrets

Auth: bearer.

Write-only secret storage. Values are never returned by any endpoint.

Request body:

{"secrets": {"OPENAI_API_KEY": "sk-..."}}

Validation:

  • Body must be {"secrets": {name: value}}.
  • Each name must be in configured_api_key_env_names(config).
  • Each value must be a non-empty string.

Invalid → 400 invalid_secrets.

Side effect: written keys are also loaded into os.environ for the running daemon.

Response: 200

{"status": "ok", "written": ["OPENAI_API_KEY"]}

GET /models

Auth: bearer.

List configured model aliases + metadata (provider-aware). The provider field is the active chat/generate provider (ollama default or chatgpt); when chatgpt, a chatgpt block with default_model / context_window / configured_models is included.

Response: 200

{
  "provider": "ollama",
  "default_alias": "glm",
  "registry": { /* models.registry from config */ },
  "info": { /* models.info from config */ },
  "chatgpt": { "default_model": "gpt-5.2", "context_window": 128000, "configured_models": [] }
}

GET /models/live

Auth: bearer.

Probe live, reachable models. Branches on models.provider:

  • ollama — queries the Ollama daemon /api/tags; on failure (503) returns the configured registry models with source: "registry" and an error.
  • chatgptauto-starts the local openai-oauth proxy via ChatGptProxyManager.ensure_running (only when signed in + auto_start; idempotent — a pre-existing proxy is reused and never stopped), then queries its /v1/models. On failure (not signed in, proxy wouldn't start, or /v1/models unreachable) returns a 503 with source: "registry" falling back to chatgpt.models / chatgpt.default_model and a human error (e.g. "Not signed in to ChatGPT — sign in via System → Models"). The WebUI model picker + System → Models use this list as the available models for both providers; switching provider invalidates the cache so it refetches immediately.

Response: 200 / 503 (fallback body still returned)

{ "models": ["glm-5.2:cloud"], "source": "ollama", "error": null }

POST /models/refresh

Auth: bearer. Ollama provider only (400 invalid_provider otherwise).

Sync models.registry to the newest versions the Ollama API actually lists (GET {ollama.host}/api/tags, bearer-authed). Every alias is bumped to the strictly newest same-family version — glm-5.2:cloudglm-5.3:cloud, including when the configured version has vanished from the catalog. Tag preference (:cloud, :8b, …) is preserved when several specs share the newest version; versionless specs (nomic-embed-text) are never touched. No pulls are issued (for Ollama Cloud a pull only registers a pointer). When updates are found they persist through the validated config-write path, so the change survives restarts; models.info labels/context windows stay operator-managed. Implementation: tools/ollama_models.py. The daemon runs the same sync at boot when models.auto_update is true (default); the WebUI model picker's refresh button triggers it for Ollama.

Response: 200

{
  "ok": true,
  "host": "https://api.ollama.com",
  "available_count": 12,
  "updates": { "glm": { "old": "glm-5.2:cloud", "new": "glm-5.3:cloud" } },
  "registry": { "glm": "glm-5.3:cloud" },
  "persisted": true
}

503 when the Ollama API is unreachable: { "ok": false, "host": "...", "error": "..." }.


GET /providers

Auth: bearer.

Return the active provider plus ChatGPT auth/proxy status. Never includes secretsauthenticated is derived from the existence of ~/.codex/auth.json (file existence only, never read).

Response: 200

{
  "provider": "chatgpt",
  "chatgpt": {
    "enabled": true,
    "authenticated": true,
    "proxy_running": true,
    "host": "127.0.0.1",
    "port": 10531,
    "default_model": "gpt-5.2",
    "we_started": true
  }
}

POST /providers/chatgpt/login

Auth: bearer.

Start a ChatGPT OAuth login (browser flow) on the server host and return the login URL. Tokens stay in openai-oauth's ~/.codex/auth.json — they never enter the request, response, or config. The WebUI surfaces the URL as a link (backend-driven OAuth; the browser SPA never handles raw tokens).

Response: 200

{ "ok": true, "url": "https://auth.openai.com/..." }

POST /providers/chatgpt/proxy/start

Auth: bearer. Ensure the local openai-oauth proxy is running. Returns {ok, base_url?, reason?}. Never spawns when not authenticated.


POST /providers/chatgpt/proxy/stop

Auth: bearer. Stop the proxy only if BreachPilot started it (we_started); otherwise a no-op that leaves an operator-started proxy alone. Returns {ok, stopped}.


GET /plugins

Auth: bearer.

List discovered plugins. Returns {"plugins": []} on any error (defensive).

Response: 200

{"plugins": [ /* list_discovered_plugins() output */ ]}

GET /skills

Auth: bearer.

List the runtime skills catalog (name, description, tags). Returns {"skills": []} on error.

Response: 200

{
  "skills": [
    {"name": "attacking-domains-end-to-end", "description": "...", "tags": ["domain", "recon"]}
  ]
}

GET /attack/modules

Auth: bearer.

List the pre-packaged attack module catalog (read-only metadata). Returns {"modules": []} on error.

Response: 200

{
  "modules": [
    {
      "name": "Log4jRCE",
      "description": "Log4j JNDI injection RCE (CVE-2021-44228)",
      "family": "web",
      "target_services": ["http", "https"],
      "target_ports": [8080, 8443, 80, 443],
      "required_cves": ["CVE-2021-44228"],
      "destructive_ics": false
    }
  ]
}

GET /skills/search

Auth: bearer.

Search runtime skills by query string. Empty q returns all (capped at 20).

Query params: q (string, default "").

Response: 200

{"results": [{"name": "...", "description": "..."}]}

POST /diagnostics/doctor

Auth: bearer.

Run the environment self-check (tools.doctor.run_doctor).

Response: 200

{"exit_code": 0}

POST /diagnostics/self-test

Auth: bearer.

Run the safe localhost smoke test (tools.self_test.run_self_test).

Response: 200

{"exit_code": 0}

Graph Explorer Routes

Source: tools/api/routes/graph_explorer.py (backed by tools/api/graph_service.py). Prefix /api/v1/graph, tag graph-explorer.

Read-only interactive investigation of the Attack Graph v2 store that graph_builder ingests per run from reports/<run_id>/ (audit + enhanced report artifacts). Scope-isolated per run (scope = run id); every query is bounded; unknown node ids return 404. All routes are gated behind api.graph_route: true in config.yaml — when disabled, each returns 404 graph_disabled. Never touches a target; never mutates run artifacts.

Bounds (authoritative ceilings, clamped server-side):

ParameterCeilingDefault
limit (graph)500300
max_hops (neighbors)41
max_nodes (neighbors)20050
max_length (paths)84
max_paths (paths)85

Errors: graph_disabled (404), run_not_found (404), node_not_found (404).

GET /graph/runs/{run_id}

Auth: bearer.

Filtered nodes + edges for a run. Unknown enum values in node_type/status are silently ignored (never raise).

ParamTypeDefaultNotes
node_typerepeated stringNodeType enum values (ip, host, service, finding, evidence, …)
statusrepeated stringNodeStatus values (confirmed, likely, suspected, unknown, refuted, exhausted)
qstring""substring match on node value
limitint300clamped to 1–500; truncated becomes true when hit
{
  "run_id": "r1",
  "scope": "run:r1",
  "nodes": [{
    "node_id": "run:r1|ip|10-0-0-5",
    "node_type": "ip",
    "value": "10.0.0.5",
    "scope": "run:r1",
    "properties": {},
    "confidence": 0.9,
    "first_seen": "2026-08-01T10:00:00Z",
    "last_seen": "2026-08-01T10:00:00Z",
    "evidence_refs": [],
    "observation_count": 0,
    "contradiction_count": 0,
    "status": "unknown",
    "source": "run"
  }],
  "edges": [{
    "edge_id": "…", "source_node_id": "…", "target_node_id": "…",
    "edge_type": "observed_on", "scope": "run:r1", "properties": {},
    "confidence": 0.5, "source": "nmap", "first_seen": "t", "last_seen": "t",
    "evidence_refs": [], "observation_count": 0, "contradiction_count": 0
  }],
  "total_nodes": 17,
  "truncated": false
}

node_type values are the real tools/intelligence/graph/types.py::NodeType members — never invented: asset, host, domain, ip, service, port, endpoint, application, technology, version, identity, role, credential_reference, trust_boundary, network_segment, vulnerability_candidate, finding, hypothesis, evidence, capability, security_control, observation. Edge types: resolves_to, hosts, exposes, runs, depends_on, reachable_from, authenticates_to, has_role, trusts, related_to, supported_by, contradicted_by, derived_from, affected_by, protected_by, connected_to, same_as, observed_on.

GET /graph/runs/{run_id}/summary

Auth: bearer.

Counts + stats chips for the run's full graph (independent of the filtered view). highest_degree_node is null when the graph has no edges.

{
  "run_id": "r1",
  "summary": {
    "nodes": { "ip": 1, "finding": 1 },
    "edges": { "affected_by": 1 },
    "total_nodes": 2,
    "total_edges": 1
  },
  "stats": {
    "hosts": 0, "domains": 0, "ips": 1, "services": 0, "findings": 1,
    "hypotheses": 0, "evidence": 0, "observations": 1,
    "vulnerability_candidates": 0,
    "confirmed": 1, "likely": 0, "refuted": 0,
    "highest_degree_node": {
      "node_id": "run:r1|ip|10.0.0.5", "value": "10.0.0.5",
      "node_type": "ip", "degree": 3
    },
    "conflict_count": 0
  }
}

GET /graph/runs/{run_id}/conflicts

Auth: bearer.

Merge-engine conflicts observed while ingesting this run's artifacts — merge conflicts are never silently hidden:

{
  "run_id": "r1",
  "conflicts": [{
    "node_value": "10.0.0.5",
    "reason": "type conflict: proposed as host, existing as ip",
    "existing_confidence": 0.5,
    "proposed_confidence": 0.6,
    "node_id": "run:r1|ip|10.0.0.5",
    "scope": "run:r1",
    "built_at": "2026-08-01T10:00:00Z"
  }]
}

GET /graph/runs/{run_id}/nodes/{node_id}

Auth: bearer.

Node details plus up to 100 connected edges and their neighbor nodes. Returns 404 node_not_found when the node is unknown or outside the run's scope.

{
  "run_id": "r1",
  "node": { "…same shape as a graph node…" },
  "edges": [],
  "neighbors": []
}

GET /graph/runs/{run_id}/nodes/{node_id}/neighbors

Auth: bearer.

Bounded BFS neighborhood including the start node. max_hops clamps to 1–4, max_nodes to 1–200. Returns 404 node_not_found for an unknown start node.

{
  "run_id": "r1",
  "start_node": { "…" },
  "nodes": [],
  "edges": []
}

GET /graph/runs/{run_id}/paths

Auth: bearer.

Bounded simple paths between two nodes. max_length clamps to 1–8, max_paths to 1–8. Unknown endpoints return an empty paths array (no error). Each path is a list of steps starting at distance 1 (the start node itself is not emitted); each step pairs the reached node with the edge traversed to get there:

{
  "run_id": "r1",
  "paths": [[
    { "distance": 1, "node": { "…" }, "edge": { "…" } },
    { "distance": 2, "node": { "…" }, "edge": { "…" } }
  ]]
}

Run Routes

Source: tools/api/routes/runs.py. Prefix /api/v1, tag runs.

POST /runs

Auth: bearer.

Create a run. Does not execute yet — it prepares a preview and (unless yes=true) creates a start_confirm decision the WebUI must answer before execution begins.

Request body (RunCreateRequest):

FieldTypeDefaultNotes
targetstringrequiredTarget IP or domain
modestring"attack"recon | attack
goalstring""Preset goal name
custom_goalstring""Free-text custom goal
recon_firstbool|nullnullnull = auto (recon-first when no goal)
modelstring|nullnullModel alias override
swarmboolfalseMulti-agent swarm
parallel_swarmboolfalseParallel swarm dispatch
criticboolfalseCritic pre-check
reflectionboolfalseReflection agent
adaptive_exploitsboolfalseAdaptive exploit mutation
long_sessionboolfalseMulti-hour attack mode
multi_model_consultbool|nullnullPeer model consultation
observer_modestring"hybrid"Observer mode
ultrathinkboolfalseDeep reasoning
skillsstring|nullnullon/off/hints/lookup
skills_includestring[][]Force-include skills
skills_excludestring[][]Force-exclude skills
resumestring""Run ID to resume from
kindstring"agent"agent | manual
yesboolfalseSkip the start_confirm gate

Response: 201

{
  "run_id": "run-abc123def456",
  "preview": {
    "run_id": "run-abc123def456",
    "target_ip": "10.0.0.50",
    "mode": "attack",
    "goal_name": "backdoor",
    "model_alias": "glm",
    "permission": "full_access",
    "destructive": true,
    "required_confirmation_text": "ALLOW 10.0.0.50",
    "budgets": { /* commands/rounds/duration */ },
    "swarm": false
  },
  "state": "awaiting_confirmation",
  "decision": {
    "id": "dec-...",
    "kind": "start_confirm",
    "required_text": "ALLOW 10.0.0.50",
    "prompt_text": "DESTRUCTIVE mode — confirm to proceed."
  }
}

When yes=true, no decision is returned and state is "queued" (execution starts immediately).

Errors: 409 conflict (a run is already active).


GET /runs

Auth: bearer.

List run history. Each row includes an AI-generated title (empty string when the titler hasn't run yet — e.g. an active or cancelled run).

Query params:

ParamTypeDefaultRange
limitint501–200
offsetint0≥ 0
sortstringcreated_descone of created_desc, created_asc, title_asc, title_desc, state_asc, state_desc

Response: 200

{
  "runs": [
    {"id": "run-...", "state": "completed", "created_at": "2026-07-31T...", "title": "Recon scan of 10.0.0.50"}
  ],
  "sort": "created_desc"
}

POST /runs/{run_id}/title

Auth: bearer.

Set or AI-regenerate a run's title. The titler model is gemma4:31b-cloud (routed through the same Ollama host/API key as the main model).

Body:

FieldTypeDefaultMeaning
titlestring | nullnullExplicit title (max 200 chars). Takes precedence over regen.
regenboolfalseIf true and title is null/empty, ask the titler model for a fresh title from the run's result/request.

Response: 200

{"run_id": "run-...", "title": "Recon scan of 10.0.0.50", "regenerated": true}

Errors: 404 (run not found). A titler failure (ollama unreachable, empty response) returns 200 with the current title unchanged — never 5xx.


GET /runs/{run_id}

Auth: bearer.

Get run details: effective state, progress, pending decisions, artifacts, result, errors.

Response: 200

{
  "id": "run-...",
  "state": "running",
  "created_at": "...",
  "updated_at": "...",
  "request": { /* full RunRequest dict */ },
  "preview": { /* full RunPreview dict */ },
  "result": { /* full RunResult dict, empty until done */ },
  "error": "",
  "cancelled_at": "",
  "resumed_from": "",
  "decisions": [
    {"id": "dec-...", "kind": "start_confirm", "status": "answered", "answer": "ALLOW 10.0.0.50"}
  ]
}

Errors: 404 Run not found.


POST /runs/{run_id}/cancel

Auth: bearer.

Cooperative cancellation + guaranteed MCP/swarm child cleanup.

Sets the CancellationToken, cancels the owning asyncio.Task, and waits up to api.shutdown_timeout_seconds (default 15s) for the task to finish. Pending decisions are expired and resolved with "" so the blocked service unblocks cleanly. The MCP subprocess tree is torn down in the service's finally blocks.

Response: 200

{"run_id": "run-...", "state": "cancelled"}

Errors: 404 not_found, 504 cancel_timeout (task did not finish in time).


POST /runs/{run_id}/resume

Auth: bearer.

Create a new execution record linked by resumed_from, reusing existing report/session state. Copies the original run's request fields, sets resume_source to the original run ID, and forces yes=false (so the new run goes through the confirmation gate).

Response: 200

{
  "run_id": "run-new...",
  "resumed_from": "run-original...",
  "preview": {"run_id": "run-new...", "target_ip": "10.0.0.50"}
}

Errors: 404 Run not found (original), 409 conflict (a run is already active).


GET /runs/{run_id}/tools

Auth: bearer.

Return the live MCP tool schemas (including plugin-contributed tools). Only meaningful while a run is active and the MCP session is open; returns {"tools": []} otherwise.

Response: 200

{"tools": [ /* MCP tool schemas (OpenAI-function-call shape) */ ]}

POST /runs/{run_id}/tools/{tool_name}/calls

Auth: bearer.

Policy-gated REST bridge for manual WebUI tool calls. Serializes through the run's tool_lock (one manual call at a time per run) so it does not race with the agent loop.

Request body (ToolCallRequest):

{"arguments": { /* tool arguments */ }}

Flow:

  1. Require an active run with an open MCP session and a loaded exploit policy.
  2. Validate tool_name exists in the live tool schemas.
  3. Acquire handle.tool_lock.
  4. Call exploit_policy.approve_action(tool_name, json.dumps(arguments), "Manual WebUI tool call"). Denied → 403 tool_denied.
  5. Call mcp_session.call_tool(tool_name, arguments=arguments). Failure → 500 tool_error.
  6. Extract text content blocks from the MCP result.

Response: 200

{"tool": "run_exploit_terminal", "result": "...text content..."}

Errors: 404 not_found (no active run), 409 no_session/no_policy, 404 tool_not_found, 403 tool_denied, 500 tool_error.


Decision Routes

Source: tools/api/routes/decisions.py. Prefix /api/v1, tag decisions.

GET /runs/{run_id}/decisions

Auth: bearer.

List pending/answered decisions for a run (oldest first).

Response: 200

{
  "decisions": [
    {
      "id": "dec-...",
      "run_id": "run-...",
      "kind": "start_confirm",
      "prompt_text": "DESTRUCTIVE mode — confirm to proceed.",
      "required_text": "ALLOW 10.0.0.50",
      "options_json": [],
      "status": "pending",
      "answer": "",
      "created_at": "...",
      "answered_at": ""
    }
  ]
}

POST /runs/{run_id}/decisions/{decision_id}

Auth: bearer.

Answer a pending decision. Works for all three decision kinds:

KindExpected answer
start_confirm (non-destructive)"y" or "yes" (case-insensitive)
start_confirm (destructive)exact match to required_confirmation_text (e.g. "ALLOW 10.0.0.50")
goal_selectthe chosen goal name
tool_approval"ALLOW <target>" to approve, anything else to deny

Request body (DecisionAnswer):

{"answer": "ALLOW 10.0.0.50"}

Behavior:

  • For start_confirm: resolves the decision and kicks off execution (transitions queuedrunning). Invalid confirmation → 400 invalid_confirmation.
  • For goal_select / tool_approval: resolves the decision future the service is awaiting. If no decisions remain pending, transitions the run back to running.
  • Emits an approval event with the answer.

Response: 200

{"decision_id": "dec-...", "status": "answered"}

Errors: 404 not_found (run), 400 no_decisions, 404 decision_not_found (decision missing, wrong run, or already answered), 400 invalid_confirmation, 409 conflict (execution already started).


Event Routes

Source: tools/api/routes/events.py. Prefix /api/v1, tag events.

GET /runs/{run_id}/events

Auth: bearer.

Replay events for a run with sequence > after. Reads from the in-memory ring buffer if the cursor is within it, otherwise from reports/<run_id>/events.jsonl.

Query params:

ParamTypeDefaultConstraint
afterint0≥ 0

Response: 200

{
  "run_id": "run-...",
  "events": [
    {
      "sequence": 1,
      "timestamp": "2026-07-31T...",
      "run_id": "run-...",
      "type": "state",
      "payload": {"state": "running"}
    }
  ]
}

Errors: 404 Run not found, 503 Event service unavailable.


WS /ws/v1/runs/{run_id}

Auth: WebSocket handshake (origin + auth message).

Live event delivery for a run. The first message must be {"auth": "<token>", "after": <int>}. after is the replay cursor — events with sequence > after are delivered first (replay), then live events stream.

A browser disconnect does not cancel the run. Reconnect is safe: the ring buffer holds recent events and JSONL is the authoritative store.

Heartbeats ({"type": "heartbeat", "run_id": "..."}) are sent every 30s of idle to keep the WS alive.

Message shape (server → client): the Event object (see Data Models).

Close codes: see Security Model.


Connections Routes

Source: tools/api/routes/connections.py (backed by tools/operator_connection/manager.py). Prefix /api/v1/connections, tag connections.

Operator-oriented overview of persisted access channels. The ConnectionManager (tools/operator_connection/manager.py:106) is the single source of truth persisted to exploit_workspace/operator_connections.json (plus per-target shards). The WebUI/API never parse that JSON directly; every read/write goes through the manager. Listener output goes through PersistentSessionManager (tools/persistent_session_manager.py:732) so the same tmux/nohup/nc back-end is reused.

StatusMeaning
activeListener running, recent beacon or successful health check
staleListener not running or no recent beacon, needs operator attention
removedGracefully marked removed (record preserved for audit)
errorHealth check encountered an error

All routes are bearer-authenticated (loopback-only) and respect operator_connection.workspace_dir (default exploit_workspace, resolved relative to config.yaml parent when relative).

GET /connections

Auth: bearer.

List all known connection records, optionally filtered.

Query params:

ParamTypeDefaultNotes
statusstring`active
targetstringExact target_ip match

Response: 200

{
  "connections": [
    {
      "connection_id": "conn-ab12cd34",
      "target_ip": "10.0.0.15",
      "method": "linux_cron",
      "callback_host": "192.168.1.5",
      "callback_port": 4444,
      "listener_name": "persist-10-0-0-15-linux-cron",
      "status": "active",
      "created_at": 1714000000.0,
      "created_at_iso": "2026-04-24T10:00:00+00:00",
      "last_beacon": 1714000012.0,
      "last_beacon_iso": "2026-04-24T10:00:12+00:00",
      "last_check": 1714000018.0,
      "last_check_iso": "2026-04-24T10:00:18+00:00",
      "check_output": "listener running",
      "implant_path": "exploit_workspace/10.0.0.15/attempt-abc/implant_linux_cron_10_0_0_15.py",
      "mitre_technique": "T1053.003",
      "os_family": "linux",
      "notes": "attempt_id=attempt-abc auto_listener=true"
    }
  ],
  "total": 1,
  "active": 1,
  "stale": 0,
  "removed": 0,
  "error": 0
}

Counts reflect the filtered set (when ?status=active, total == active). The WebUI recomputes KPI cards from the same payload and also shows the badge count (active only) beside Connections in the sidebar (webui/src/components/Layout.tsx:60).

Polling: WebUI useConnections adaptive — 12s while any active, 15s while any stale, else 30s; respects staleTime 8s, refetchOnWindowFocus:false.

Errors: 400 invalid status, 401 missing/invalid bearer.


GET /connections/{connection_id}

Auth: bearer.

Return the complete connection record.

Response: 200 — single OperatorConnection JSON (same shape as an element of GET /connections).

Errors: 400 malformed connection_id (contains .., /, or invalid chars), 404 not found (standard {error:{code:http_error,message:Connection not found,...}} envelope), 401.


POST /connections/{connection_id}/check

Auth: bearer.

Perform the connection/listener health-check operation. Reuses the existing persistent session abstractions rather than spawning an unrelated mechanism.

Steps:

  1. Resolve the connection through ConnectionManager.get.
  2. Read listener output via PersistentSessionManager.read_listener_output(listener_name, lines=100) (bounded, never unlimited).
  3. Check list_all_sessions() fallback for running state.
  4. Determine healthy = listener running && no LOG_NOT_FOUND style output.
  5. Call ConnectionManager.mark_check(connection_id, output, healthy) — updates last_check (now), check_output (truncated 2000), status = active if healthy else stale. On hard error promotes to error.
  6. Return the updated connection.

Does not block indefinitely — the listener read is bounded and synchronous; no remote implant command is executed over the network (the verify command for the implant is surfaced to the operator via check_connection MCP tool, not here).

Response: 200 — updated OperatorConnection.

Errors: 400 malformed id, 404 not found, 500 on unexpected failure (never exposes filesystem paths).

WebUI: useCheckConnection mutation invalidates ["connections"], ["connections", id], and ["connections", id, "listener"] so the table, details drawer, and live listener output all refresh immediately.


POST /connections/{connection_id}/remove

Auth: bearer.

Use the existing removal lifecycle (graceful). Calls ConnectionManager.mark_removed(connection_id) which sets status=removed and persists — the record is preserved for audit, not hard-deleted. Best-effort also stops the associated listener via PersistentSessionManager.stop_listener / stop_background_job (failure to stop does not fail the request).

The UI action never edits operator_connections.json directly.

Response: 200

{
  "connection": { /* updated OperatorConnection with status removed */ },
  "removed": true,
  "listener_stopped": true
}

listener_stopped is false when the listener was already stopped or could not be stopped.

Errors: 400 malformed id, 404 not found, 401.

WebUI: confirmation dialog required; shows Target + Listener; after success closes/updates drawer, invalidates queries, updates table/sidebar badge, shows toast.


GET /connections/{connection_id}/listener

Auth: bearer.

Expose recent output associated with that connection's listener_name via PersistentSessionManager.read_listener_output. Bounded only — ?lines clamped 1–500 (default 100), and output truncated to 16k chars server-side. The raw terminal log is returned as plain text (preserved newlines, never rendered as HTML).

Query params:

ParamTypeDefaultRangeNotes
linesint1001–500Last N lines from the listener log

Response: 200

{
  "connection_id": "conn-ab12cd34",
  "listener_name": "persist-10-0-0-5-linux-cron",
  "output": "...",
  "updated_at": "2026-04-24T10:00:20+00:00",
  "running": true,
  "status": "running"
}

status is running|stopped|not_found|error. running mirrors PersistentSessionManager liveness. output may be LOG_NOT_FOUND: ... when no log file exists — the WebUI shows the “listener unavailable / stopped” empty state instead of blanking the page.

Missing/stopped listener is handled cleanly — never 500 unless the connection itself is missing (404). The entire /connections page does not blank out because one listener endpoint failed.

Polling: WebUI useConnectionListener with enabled only while the details drawer is open and status !== removed, staleTime 2s, refetchInterval 3s, Live indicator while polling + active.


Data Models

Source: tools/run_service/models.py.

RunRequest

The transport-neutral description of an assessment the operator wants to run. Built from CLI args or from POST /runs JSON.

FieldTypeDefaultNotes
targetstrrequiredIP or domain
moderecon|attack"attack"
goal_namestr""Preset goal
custom_goalstr""Free-text goal
recon_firstbool|NoneNoneNone = auto
model_aliasstr""
config_pathPathconfig.yamlSet by manager
reports_dirPathreportsSet by manager
swarmboolfalse
parallel_swarmboolfalse
criticboolfalse
reflectionboolfalse
adaptive_exploitsboolfalse
long_sessionboolfalse
multi_model_consultbool|NoneNone
observer_modestr"hybrid"
ultrathinkboolfalse
debugboolfalse
plainboolfalse
json_outputboolfalse
yesboolfalseSkip start_confirm gate
skills_modestr|NoneNoneon/off/hints/lookup
skills_includestr[][]
skills_excludestr[][]
skills_no_reselectboolfalse
resume_sourcestr""Run ID
kindRunKindAGENTagent | manual
interactiveboolfalseAPI-only flag

RunPreview

Everything the operator sees at the ready-to-begin gate, computed by AssessmentService.prepare before any I/O side effects.

FieldTypeNotes
run_idstr
reports_dirPath
config_pathPath
target_ipstrResolved/normalized
original_targetstrWhat the operator passed
resolved_ipstr|NoneFor domain targets
resolved_domainstr|NoneFor domain targets
moderecon|attack
goal_namestr
goal_descriptionstr
model_aliasstr
model_labelstr
transport_summarystr
permissionstrread_only/approve_only/full_access
attack_modebool
swarmbool
parallel_swarmbool
multi_modelbool
destructivebool
required_confirmation_textstr"" for non-destructive; "ALLOW <ip>" for destructive
budgetsdictcommands/rounds/duration
skill_activationslist[dict]
skill_errorslist[str]
resumed_fromstr

RunResult

Sanitized, serializable outcome of a completed/failed run.

FieldTypeNotes
run_idstr
target_ipstr
moderecon|attack
goal_namestr
goal_descriptionstr
total_actionsint
workspacestr
audit_pathstr
recordslist[dict]
messageslist[dict]
errorstr
swarm_resultdict|None
active_skillslist[dict]
outcome_summarystr
telemetrydict|None
safety_reviewdict|None
reports_dirstr
summary_pathstr
run_json_pathstr

Decision

A point where the run pauses for operator input.

FieldTypeNotes
idstrAssigned by broker
run_idstr
kindDecisionKindstart_confirm | goal_select | tool_approval
prompt_textstr
required_textstrExact match required (e.g. "ALLOW 10.0.0.50")
optionslist[dict]For goal_select: [{name, description, ...}]
statusDecisionStatuspending | answered | denied | expired
answerstr
created_atstrISO UTC
answered_atstrISO UTC

Event

A structured event emitted during a run.

FieldTypeNotes
sequenceintMonotonic per run
timestampstrISO UTC
run_idstr
typestrSee Event Types
payloaddictSanitized by sanitize()

Event Types

Constants from tools/run_service/models.py. All payloads are sanitized before persistence/WebSocket delivery.

TypePayloadWhen
state{"state": <RunState>, "result"?: <RunResult dict>}Run state transition
bootboot step infoMCP boot step ([BOOT]/[OK] markers)
progressround/action/phaseHeartbeat
goal_suggestionssuggested goalsRecon-first goal suggestion
assistantLLM output textLLM response
tool_requesttool name + argsAgent decided to call a tool
tool_starttool nameTool call started
tool_resulttool name + resultTool call finished
approval{"decision_id", "kind", "prompt_text", "required_text", "options"?, "status"?, "answer"?}Tool approval requested/answered
swarmswarm progressSwarm update
artifactfile path + kindFile written (report/audit/etc)
completioncompletion infoRun completed
error{"message": ...}Run error
heartbeat{"run_id": ...}WS keepalive (every 30s idle)

Config Reference

The api block in config.yaml:

api:
  enabled: true
  host: 127.0.0.1              # loopback-only in v1; no public-bind override
  port: 8765
  token_file: .webui_secret_key   # gitignored; created on first boot
  allowed_origins: []          # extra loopback origins for CORS/WS
  event_buffer_size: 256       # in-memory ring buffer per run
  shutdown_timeout_seconds: 15 # graceful shutdown wait
  serve_webui: false           # mount built webui/dist/ at / when true (--web sets this in memory)
  max_concurrent_runs: 3       # D3: N concurrent runs (lab default 3; 1 = legacy single-run 409)
  multi_operator: true         # D4: user accounts + annotations (loopback-only)
  graph_route: true            # D3: attack-path DAG API route
KeyTypeDefaultNotes
enabledbooltrue
hoststr127.0.0.1Must be loopback; assert_api_loopback enforces
portint8765
token_filestr.webui_secret_key256-bit token written here; 0o600
allowed_originsstr[][]Extra loopback HTTP(S) origins for CORS/WS
event_buffer_sizeint256In-memory ring per run; ≥ 1
shutdown_timeout_secondsint15Graceful cancel wait before forcing cleanup
serve_webuiboolfalseMount webui/dist/ at / when true. --web sets this in memory only (never written to config.yaml). Requires webui/dist/index.html to exist.
max_concurrent_runsint3D3: N concurrent runs; 1 = legacy single-run 409 (tools/api/run_manager.py:22)
multi_operatorbooltrueD4: user accounts + annotations (tools/api/auth.py:60)
graph_routebooltrueAttack-path DAG route (tools/api/routes/graph_explorer.py:30)

Env overrides:

  • BREACHPILOT_API_TOKEN — bearer token (precedes token_file).
  • BREACHPILOT_API_KEY_FILE — API key file path (for GET/PUT /secrets).

Persistence Schema

reports/api_runtime.db (SQLite). Separate from Flow B's research.db. Thread-safe via a threading.Lock around every connection.

_migrations

ColumnType
versionINTEGER PK
applied_atTEXT

runs

ColumnTypeDefaultNotes
idTEXT PKrun-<12hex>
created_atTEXTISO UTC
updated_atTEXTISO UTC
stateTEXTdraftRunState value
request_jsonTEXT{}Serialized RunRequest
preview_jsonTEXT{}Serialized RunPreview
result_jsonTEXT{}Serialized RunResult
resumed_fromTEXT""Original run ID
errorTEXT""
cancelled_atTEXT""Set when state → cancelled

Indexes: idx_runs_state on state.

decisions

ColumnTypeDefaultNotes
idTEXT PKdec-<12hex>
run_idTEXTFK → runs(id) ON DELETE CASCADE
kindTEXTDecisionKind value
prompt_textTEXT""
required_textTEXT""
options_jsonTEXT[]For goal_select
statusTEXTpendingDecisionStatus value
answerTEXT""
created_atTEXTISO UTC
answered_atTEXT""ISO UTC

Indexes: idx_decisions_run_id on run_id.

Recovery on startup: recover_interrupted() marks live runs interrupted and expires their pending decisions in one transaction.


End-to-End Flow

A typical WebUI session against the API:

  1. Create runPOST /runs with target + mode + flags.

    • Service prepares a RunPreview (resolves target, computes permission/destructive flag, budgets).
    • If yes=false (default): creates a start_confirm decision, run enters awaiting_confirmation. Response includes the decision the WebUI must answer.
    • If yes=true: run enters queued and execution starts immediately.
  2. (Optional) ConfirmPOST /runs/{id}/decisions/{decision_id} with the answer.

    • Destructive runs require the exact required_confirmation_text (e.g. "ALLOW 10.0.0.50").
    • Non-destructive runs accept "y"/"yes".
    • On success: run transitions queuedrunning, the asyncio.Task running AssessmentService.execute starts.
  3. Stream events — open WS /ws/v1/runs/{run_id} with {"auth": "<token>", "after": 0}.

    • Replay events with sequence > after first, then live events.
    • Reconnect any time with the last sequence you saw as after — the ring buffer + JSONL cover the gap.
    • A disconnect does not cancel the run.
  4. Answer mid-run decisionsPOST /runs/{id}/decisions/{decision_id}.

    • goal_select: choose a goal from recon suggestions.
    • tool_approval: "ALLOW <target>" to approve (only under approve_only policy; full_access auto-approves).
    • Run transitions back to running when no decisions remain pending.
  5. (Optional) Manual tool callsPOST /runs/{id}/tools/{tool_name}/calls.

    • Policy-gated, serialized through the run's tool_lock.
    • Requires the MCP session to be open (run must be running).
  6. CancelPOST /runs/{id}/cancel.

    • Cooperative: sets the CancellationToken, cancels the task, waits up to shutdown_timeout_seconds.
    • Pending decisions expired and resolved with ""; MCP subprocess tree torn down.
  7. Poll or stream to completion — final state event is completed or failed (with result and error payloads). GET /runs/{id} returns the full result.

Resume

POST /runs/{id}/resume creates a new run with resume_source set to the original run ID, reusing existing report/session state. The new run goes through the normal confirmation gate (yes=false).

Manual mode

kind: "manual" runs do not run the agent loop — they only expose the MCP tool gateway (POST /runs/{id}/tools/{tool_name}/calls). The WebUI drives tool calls directly.

source: repo docs (build sync)Edit this page on GitHub →