Compare commits

..
Author SHA1 Message Date
bearsyankees f5a467fca5 Add reverse-engineering security skills 2026-08-19 12:06:11 -04:00
22 changed files with 937 additions and 1628 deletions
-29
View File
@@ -167,15 +167,10 @@ strix view
# ...or open a specific run by name
strix view my-run-name
# Expose the viewer on all IPv4 interfaces at a fixed port
strix view --host 0.0.0.0 --port 8080 --no-open
```
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
### What's in the dashboard
- **Overview**: run status, target, and a severity breakdown of everything found so far.
@@ -320,30 +315,6 @@ strix auth status # show the active sign-in
strix auth logout # forget the sign-in
```
#### Connect your own MCP servers
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
```json
[
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
},
{
"name": "github",
"transport": "http",
"url": "https://api.githubcopilot.com/mcp/",
"auth": { "kind": "bearer", "token": "your-token" },
"allowed_tools": ["list_issues"]
}
]
```
Each server's tools are namespaced by `name` (for example `local_fs.read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
**Recommended models for best results:**
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
+1 -2
View File
@@ -47,8 +47,7 @@
"pages": [
"integrations/github-actions",
"integrations/ci-cd",
"integrations/coding-agents",
"integrations/mcp"
"integrations/coding-agents"
]
},
{
-113
View File
@@ -1,113 +0,0 @@
---
title: "MCP Servers"
description: "Connect your own MCP servers and expose their tools to the agent"
---
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and expose their tools to the agent during a run. Use this to give the agent extra capabilities — reading files, querying an issue tracker, or any other tool a server offers.
## Setup
Create the file `~/.strix/mcp-servers.json`. It holds a JSON list of the servers you want the agent to reach. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server.
Create the directory if it does not exist, then write the file:
```bash
mkdir -p ~/.strix
```
Paste the servers you want into `~/.strix/mcp-servers.json`. The example below shows one of each transport — a local filesystem server over `stdio` and a remote GitHub server over `http` with a bearer token:
```json
[
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
},
{
"name": "github",
"transport": "http",
"url": "https://api.githubcopilot.com/mcp/",
"auth": { "kind": "bearer", "token": "your-token" },
"allowed_tools": ["list_issues"]
}
]
```
Strix reads this file at the start of each run. There is no default file, so no MCP tools are loaded until you create it. Edit `command`, `args`, `url`, and `token` to match your own servers.
## Fields
<ParamField path="name" type="string" required>
A short label for the connection. Each server's tools are namespaced by
`name` (for example `local_fs.read_file`), so two servers can offer the same
tool name without colliding.
</ParamField>
<ParamField path="transport" type="string">
`stdio` for a local subprocess server, or `http` for a remote server.
</ParamField>
<ParamField path="command" type="string">
For `stdio` servers: the executable Strix launches (for example `npx`).
</ParamField>
<ParamField path="args" type="array">
For `stdio` servers: the arguments passed to `command`.
</ParamField>
<ParamField path="url" type="string">
For `http` servers: the server endpoint URL.
</ParamField>
<ParamField path="auth" type="object">
For `http` servers that need a bearer token:
`{ "kind": "bearer", "token": "your-token" }`.
</ParamField>
<ParamField path="allowed_tools" type="array">
Restrict which tools the agent can call. Omit it to expose every tool the
server offers, or set it to a list of tool names to allow only those.
</ParamField>
<ParamField path="notes" type="string">
Free-text notes for the agent about what this connection is and how you want
it used, for example "Staging analytics database, read-only, prefer aggregate
queries." When set, the notes are given to the agent at the start of the run
as a description of the connection.
</ParamField>
## Choosing connections per run
By default every connection in the file is used on each run. To narrow it for a
single run without editing the file, use either flag (both repeatable):
```bash
strix --mcp-server github -t ... # use only the named connection(s)
strix --mcp-exclude staging-db -t ... # use everything except the named one(s)
```
`--mcp-server` keeps only the connections you name; `--mcp-exclude` drops the
ones you name. Connection names must be unique in the file; if two entries share
a name, the first is kept and the rest are ignored.
## Pointing at a different file
To read the config from another path instead of `~/.strix/mcp-servers.json`, either pass `--mcp-config <path>` on the command line:
```bash
strix --mcp-config ./mcp-servers.json -t ...
```
or set the `STRIX_MCP_CONFIG` environment variable to that path. The flag takes precedence when both are given.
## Startup confirmation
When servers are configured, Strix prints a one-line summary at scan startup, for example `MCP: connected 1 server (14 tools): local_fs`, so you can confirm your servers connected.
## Behavior
- The config file is optional. Without it, a run simply gets no MCP tools.
- A server that fails to connect is skipped and logged, and the run continues without it.
- A single malformed entry is skipped without blocking the valid ones.
-2
View File
@@ -241,8 +241,6 @@ ignore = [
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
"tests/test_unknown_tool_recovery.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Fake MCP server matches the SDK's MCPServer signature; its args are unused.
"tests/test_mcp_client.py" = ["S105", "S106", "ARG002"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
-51
View File
@@ -51,12 +51,10 @@ from strix.tools.output_store import (
if TYPE_CHECKING:
from agents.mcp import MCPServer
from agents.memory import SQLiteSession
from agents.result import RunResultBase
from strix.runtime.status import StatusSink
from strix.tools.mcp import ConnectedMcpServer
logger = logging.getLogger(__name__)
@@ -64,33 +62,6 @@ logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
def _mcp_startup_summary(connections: list[ConnectedMcpServer]) -> str:
"""One user-facing line summarizing the MCP servers that connected."""
server_count = len(connections)
tool_count = sum(c.tool_count for c in connections)
servers_word = "server" if server_count == 1 else "servers"
tools_word = "tool" if tool_count == 1 else "tools"
names = ", ".join(c.name for c in connections)
return f"MCP: connected {server_count} {servers_word} ({tool_count} {tools_word}): {names}"
def _mcp_connection_notes(connections: list[ConnectedMcpServer]) -> str | None:
"""A block describing the connections the user left notes on, for the agent.
Only connections with notes are listed, so the note describes the connection
once rather than being repeated onto every tool. Returns ``None`` when no
connection has notes.
"""
noted = [(c.name, c.notes) for c in connections if c.notes]
if not noted:
return None
lines = "\n".join(f"- `{name}.*` tools: {notes}" for name, notes in noted)
return (
"The user connected these MCP servers for this run and left notes on how "
f"to use each:\n{lines}"
)
def _merge_root_prompt_context(
scope_context: dict[str, Any],
extra_system_prompt_context: dict[str, Any] | None,
@@ -282,7 +253,6 @@ async def run_strix_scan(
configure_spill_writer(_spill_to_workspace)
sessions_to_close: list[SQLiteSession] = []
mcp_servers: list[MCPServer] = []
try:
targets = scan_config.get("targets") or []
@@ -328,24 +298,6 @@ async def run_strix_scan(
system_prompt_context=root_context,
)
# Connect any MCP servers the user listed in ~/.strix/mcp-servers.json and
# register their tools before the agent is built. Fail-open: a missing
# config, or a server that will not connect, must never break a run.
from strix.tools.mcp import connect_mcp_servers, load_user_mcp_configs
try:
user_mcp_configs = load_user_mcp_configs()
if user_mcp_configs:
connections = await connect_mcp_servers(user_mcp_configs)
mcp_servers = [c.server for c in connections]
if connections:
report(_mcp_startup_summary(connections))
notes_block = _mcp_connection_notes(connections)
if notes_block:
root_task = f"{root_task}\n\n{notes_block}"
except Exception:
logger.exception("Failed to connect user MCP servers; continuing without them")
root_agent = build_strix_agent(
name="Root Agent",
skills=skills,
@@ -520,9 +472,6 @@ async def run_strix_scan(
for s in sessions_to_close:
with contextlib.suppress(Exception):
s.close()
for mcp_server in mcp_servers:
with contextlib.suppress(Exception):
await mcp_server.cleanup() # type: ignore[no-untyped-call]
with contextlib.suppress(Exception):
await coordinator._maybe_snapshot()
if cleanup_on_exit:
-39
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
@@ -220,30 +219,6 @@ Examples:
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
parser.add_argument(
"--mcp-config",
type=str,
metavar="PATH",
help="Path to an MCP servers JSON file to use instead of ~/.strix/mcp-servers.json.",
)
parser.add_argument(
"--mcp-server",
dest="mcp_server",
action="append",
metavar="NAME",
help="Use only this MCP connection for the run, by its config name "
"(repeatable). Every other configured connection is skipped.",
)
parser.add_argument(
"--mcp-exclude",
dest="mcp_exclude",
action="append",
metavar="NAME",
help="Skip this MCP connection for the run, by its config name (repeatable).",
)
parser.add_argument(
"--max-budget",
"--max-budget-usd",
@@ -292,20 +267,6 @@ Examples:
if args.config:
apply_config_override(validate_config_file(args.config))
if args.mcp_config:
mcp_config_path = Path(args.mcp_config).expanduser()
if not mcp_config_path.is_file():
parser.error(f"--mcp-config file not found: {args.mcp_config}")
# The MCP loader reads this env var as its config-path override, so
# setting it here makes the flag win over the default location.
os.environ["STRIX_MCP_CONFIG"] = str(mcp_config_path)
# The MCP loader reads these as its per-run include/exclude selection.
if args.mcp_server:
os.environ["STRIX_MCP_ONLY"] = ",".join(args.mcp_server)
if args.mcp_exclude:
os.environ["STRIX_MCP_EXCLUDE"] = ",".join(args.mcp_exclude)
if args.update:
sys.exit(0 if self_update() else 1)
@@ -22,20 +22,19 @@ func statusIcon(status string) (string, lipgloss.Style) {
return "○ Unknown", Dim()
}
// renderGenericTool ports registry._render_default_tool_widget. It shows the
// tool name, its arguments, and a status line only. The raw result is
// deliberately not rendered: a generic/MCP result (e.g. a multi-kilobyte JSON
// payload from a database query tool) is noise on screen, and the agent narrates
// what it got in its next message. The full result still lives in the event
// data, the run log, and the `strix view` viewer.
func renderGenericTool(name string, args map[string]any, status string) string {
// renderGenericTool ports registry._render_default_tool_widget.
func renderGenericTool(name string, args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
for _, k := range SortedKeys(args) {
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
}
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
if (status == "completed" || status == "failed" || status == "error") && result != nil {
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result: ") + StringValue(result))
} else {
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
}
return b.String()
}
@@ -88,7 +87,7 @@ func Tool(data map[string]any) string {
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
return renderProxyTool(name, args, result, status)
}
return renderGenericTool(name, args, status)
return renderGenericTool(name, args, result, status)
}
// ---------------------------------------------------------------------------
@@ -203,7 +203,7 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
{
"unknown tool falls back to generic",
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
[]string{"brand_new_tool", "alpha", "Done"},
[]string{"brand_new_tool", "alpha", "Result:", "done"},
},
}
@@ -214,18 +214,6 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
}
}
func TestGenericToolOmitsRawResult(t *testing.T) {
// The generic/MCP renderer shows tool name, args, and a status line only —
// never the raw result payload.
long := strings.Repeat("x", 5000)
out := ansi.Strip(Tool(tool("db_query", map[string]any{"query": "select 1"}, long, "completed")))
requireContains(t, out, "db_query", "query", "Done")
if strings.Contains(out, "Result:") || strings.Contains(out, strings.Repeat("x", 20)) {
t.Fatalf("generic result body must not be rendered:\n%s", out)
}
}
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
lines := make([]string, 16)
for i := range lines {
+1 -5
View File
@@ -45,11 +45,7 @@ def run_view(argv: list[str]) -> None:
default=0,
help="Port to serve on (default: an available ephemeral port).",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host to bind to (default: 127.0.0.1; use 0.0.0.0 for all IPv4 interfaces).",
)
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
parser.add_argument(
"--no-open",
action="store_true",
+20 -22
View File
@@ -135,9 +135,8 @@ class _ViewerState:
# exchanged for a session cookie only when presented on the initial page
# load. It is the request-level authorization the review asked for:
# reachability of the port (e.g. when bound with ``--host``) is not
# enough to read run data, steer a live scan, trigger a report, or
# browse history -- the token is never handed to a caller who merely
# reaches ``/``.
# enough to steer a live scan, trigger a report, or browse history --
# the token is never handed to a caller who merely reaches ``/``.
self.session_token = secrets.token_urlsafe(32)
# Finalized in ``serve()`` once the port is known (the server binds
# after this state is constructed); see SESSION_COOKIE_PREFIX.
@@ -235,11 +234,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.end_headers()
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The cross-run history list (/api/runs) unlocks its entries only for
# a caller that holds this process's session capability *and* is
# email verified, so merely reaching an exposed --host port never
# leaks the run list (the payload still advertises the count as a
# teaser).
# The launched run is always viewable with no verification. The
# cross-run history list (/api/runs) unlocks its entries only for a
# caller that holds this process's session capability *and* is email
# verified, so merely reaching an exposed --host port never leaks the
# run list (the payload still advertises the count as a teaser).
if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked)
@@ -254,13 +253,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._handle_auth_status()
return
# All remaining GET endpoints expose run metadata or scan output.
# Require the capability even for the run used to launch the viewer;
# reachability of an exposed --host port must not grant data access.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
run_values = query.get("run")
run_param = run_values[0] if run_values else None
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
@@ -268,12 +260,18 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return
# Any run other than the one used to launch the viewer is part of the
# email-gated history. The session check above applies to both paths;
# verification adds a second gate for historical run data.
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
# The launched run is always viewable. Any *other* run's data is part
# of the gated history: it needs this process's session capability
# (so merely reaching an exposed --host port is not enough) *and*
# email verification -- otherwise knowing a run name would leak its
# metadata, vulnerabilities, report, and transcript.
if run_dir.resolve() != state.run_dir.resolve():
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
if path == "/api/run":
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
@@ -387,7 +385,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
except auth.RelayError as exc:
self._send_relay_error(exc)
return
# The password is returned only to a session-authorized browser.
# The password is returned only to the local (127.0.0.1) browser.
self._send_json(
HTTPStatus.OK,
{"ok": True, "password": password, "filename": filename},
+6
View File
@@ -55,6 +55,12 @@ Notable LLM security skills:
- `llm_applications` (technologies): end-to-end OWASP 2026 LLM01-LLM10 coverage across models, RAG, vectors, agents, tools, outputs, supply chain, and resource controls
- `llm_prompt_injection` (vulnerabilities): deep direct, indirect, multimodal, memory, and tool-result prompt-injection testing
Notable reverse-engineering skills:
- `advisory_to_poc` (custom): advisory-to-root-cause workflow for patch diffing, public PoCs, and detector design
- `appliance_firmware` (technologies): appliance artifact, runtime, and install-state analysis
- `protocol_reverse_engineering` (protocols): stateful/custom protocol reconstruction and controlled harnessing
- `memory_corruption` (vulnerabilities): native crash triage, primitive quality, and exploitability constraints
---
## 🎨 Creating New Skills
+235
View File
@@ -0,0 +1,235 @@
---
name: advisory-to-poc
description: Vulnerability research workflow for turning advisories, patches, release artifacts, public PoCs, and incident clues into root-cause analysis, safe reproducers, reliable detectors, patch-bypass review, and adjacent-bug hypotheses
---
# Advisory to PoC
Use this skill for authorized product-security and n-day research where the starting point is an advisory, fixed release, patch, public PoC, or incident evidence rather than a known vulnerable endpoint.
The goal is a version-bounded root-cause explanation and reliable, reproducible validation. Do not equate a changed function, crash, scanner hit, or advisory claim with exploitability.
## Evidence Ledger
Keep facts, inferences, and experiments separate:
| Type | Examples |
|---|---|
| Published fact | affected versions, CWE, exposed feature, vendor mitigation |
| Artifact fact | changed function, new validation, removed route, configuration delta |
| Inference | likely attacker-controlled field, suspected auth path, probable sink |
| Experiment | vulnerable response, fixed response, crash, OAST callback, file canary |
Record source URL, artifact hash, product edition/branch, build number, platform, configuration, and date. Re-check assumptions whenever the experimental result conflicts with the advisory narrative.
## Research Workflow
### 1. Scope the Claim
- Extract affected and fixed versions, branches, platforms, roles, protocols, and feature/configuration prerequisites.
- Note whether the vendor describes impact, root cause, mitigation, or only a CWE category.
- Treat bundled CVEs and large release rollups as multiple candidate changes until proven otherwise.
- Identify whether the issue is pre-auth, low-privilege, post-auth, local, or requires a victim/session bridge.
### 2. Acquire Comparable Artifacts
Prefer the closest vulnerable/fixed pair for the same edition and platform:
- source commits, tags, tests, pull requests, and dependency lockfiles
- packages, containers, installers, JAR/WAR/DLL/assemblies, Python bytecode, firmware, or VM images
- web-server/reverse-proxy configuration, service definitions, scripts, and bundled third-party components
- documentation and shipped examples that reveal routes, protocols, defaults, or extension points
Hash originals and work on copies. Preserve installation lineage: default credentials, generated keys, legacy files, and retained configs may matter even if a fresh fixed install does not contain them.
### 3. Reduce Diff Noise
Start with inventories before line-by-line analysis:
- added/removed/renamed files and dependencies
- changed routes, authorization annotations, allowlists/denylists, parser calls, command construction, length checks, and deserialization types
- edge configuration changes that block or rewrite a route without changing application code
- tests added, removed, or updated; these often encode a near-ready reproducer
- sibling call sites of the changed helper or validator
For binaries, combine string/import/symbol diffing with a decompiler and a second diffing method when possible. Large compiler or bundled-library changes create false clusters; anchor on advisory-relevant constants, protocol handlers, response strings, and call graphs.
### 4. Map External Reachability
Work from both directions:
```text
external listener -> edge config -> router -> authentication -> parser -> sink
known changed sink -> callers -> route/protocol -> authentication -> external listener
```
Inventory auxiliary listeners, management agents, sidecars, localhost APIs, custom RPC services, CGI/script dispatch, and framework direct-component routes. Do not assume the main web UI's authentication protects every product service.
Record branch-specific and configuration-specific exposure. A powerful sink behind a disabled feature or unreachable route is not a pre-auth vulnerability.
### 5. Explain the Patch Mechanism
State what security invariant the patch tries to restore:
- bounds, termination, initialization, or length/type consistency
- authentication/authorization before dispatch
- canonicalization before comparison
- allowlisted deserialization or reflection targets
- safe command/process APIs instead of shell construction
- file path confinement and extension/handler restrictions
- route removal or edge blocking
- session-field filtering or trustworthy state reconstruction
Then ask what the patch did not change: alternate callers, sibling parsers, secondary routes, nested gadgets, transitive deserialization, old aliases, different protocol handlers, and edge/application disagreement.
### 6. Build a Reproducer Ladder
Escalate one capability at a time:
1. **Presence** - product/version/protocol fingerprint with low noise
2. **Reachability** - expected route/parser/handler responds
3. **Security differential** - unauthorized behavior differs from a denied control
4. **Primitive** - safe read, controlled callback, canary write, harmless constructor, or deterministic crash in an isolated lab
5. **Impact** - demonstrate the requested authorized impact and preserve its prerequisites
Prefer distinctive non-secret response structure, benign errors, OAST DNS/HTTP callbacks, inert file markers, or no-op commands. For deserialization, use a non-executing network gadget before command execution. For memory corruption, establish the bug and mitigation constraints in a lab; a connection close or crash is not proof of RCE.
### 7. Calibrate on Controls
Run the same reproducer against:
- vulnerable version
- fixed version
- unaffected neighboring version where available
- feature disabled / hardened configuration
- malformed but non-triggering negative input
- authentication present vs absent, if the claim crosses an auth boundary
Repeat enough times to distinguish deterministic behavior from crashes, timing noise, worker restarts, load balancers, and transient network failures.
### 8. Hunt Adjacent and Partial Fixes
After reproducing the primary issue:
- enumerate every call site of the patched function/validator
- cluster nearby handlers using the same parser, session format, command wrapper, or file primitive
- replay the old PoC and structural variants against the first fixed version
- inspect whether the patch blocks the route while leaving the sink reachable elsewhere
- test nested/transitive objects rather than only top-level denylisted types
- check whether one advisory/CVE bundles multiple distinct vulnerable paths
Do not call a variant a bypass until the fixed version demonstrably remains vulnerable.
## Tool Routing
Use the lightest maintained tool that answers the current question. Pin versions in research notes and preserve generated outputs so another analyst can reproduce the diff.
### Artifact and Package Diff: diffoscope
[diffoscope](https://diffoscope.org/) is the default first pass for packages, directories, archives, and binaries. Use it to build a changed-file/config/package manifest before opening a decompiler. For hostile artifacts, keep inputs read-only, disable network, and run the helper-heavy comparison in an isolated environment.
### Firmware and Appliance Artifacts
When the starting point is firmware, a virtual appliance, or a nested image format, load `appliance_firmware`. That skill owns extraction, package/rootfs/runtime correlation, Ghidra/BinDiff routing, overlay/install-state analysis, and device-lifecycle caveats.
### Java/JVM: Vineflower
Use maintained [Vineflower](https://github.com/Vineflower/vineflower) for JAR/class decompilation. Diff archive inventories before decompiled text; compiler, obfuscator, and synthetic-code changes produce noise. Confirm suspicious control flow with bytecode (`javap -c`) rather than treating reconstructed Java as source truth.
### .NET: ILSpy / ilspycmd
Use [ILSpy](https://github.com/icsharpcode/ILSpy) for managed assemblies. Work offline, inspect IL/metadata when the C# reconstruction is ambiguous, and use only GitHub Releases or NuGet.
### Native Code: Ghidra and BinDiff
Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for cross-architecture disassembly/decompilation and [BinDiff](https://github.com/google/bindiff) only after the file/package diff has narrowed the relevant binaries. Keep the toolchain pinned, offline where practical, and non-executing. Decompiler output and similarity scores are triage aids, not proof.
## Source and Binary Techniques
### Source-Available Products
- Search route declarations, filters/interceptors, auth decorators, and direct framework component dispatch.
- Trace attacker-controlled fields through type coercion, validation, shell/process APIs, filesystem operations, reflection, template/XSLT evaluation, and deserialization.
- Compare callers, not just the patched callee. The same helper may be safe in one route and exposed in another.
- Read tests and examples for expected protocol syntax and serialized message shapes.
### Managed Artifacts
- Decompile JAR/WAR and .NET assemblies; diff namespaces/classes/method bodies and embedded configuration.
- Trace public setters, opaque identifiers, type metadata, and framework serialization hooks.
- Inspect bundled libraries and version changes, but prove application reachability before assigning impact.
### Native Binaries and Firmware
- Inventory architecture, mitigations, imports, strings, services, and exposed ports before deep reversing.
- Diff functions around new bounds checks, initialization, string termination, length casts, command builders, and protocol parsers.
- Reconstruct the smallest valid protocol state machine before mutating the suspected field.
- Use debuggers, sanitizers, traces, and process monitors inside an isolated lab when available.
- Separate bug existence from exploitability under ASLR, NX, stack canaries, allocator behavior, architecture, and restart model.
### Public PoC or Incident First
- First decompose and neutralize a public or captured PoC; reproduce its stages in an isolated lab while preserving the headers, ordering, sessions, and negotiation relevant to each stage.
- Decompose the PoC into stages and identify the oracle for each stage.
- Work backward from the final sink to root cause and forward from the entry point to confirm reachability.
- If no patch pair exists, controlled honeypot/instrumentation can reveal in-the-wild request structure; never expose a live vulnerable system beyond an isolated, monitored environment.
Pair `protocol_reverse_engineering` when the external entry point is binary, TLS-wrapped, message-oriented, or stateful.
## Detector Design
A detector must distinguish the vulnerable behavior reliably from fixed and unaffected behavior:
- match a structural response or deterministic state change, not a secret value
- use a unique per-target canary and clean it up when the test writes data
- distinguish patched denial from generic 404/500, WAF blocking, authentication failure, and connection loss
- complete protocol/session prerequisites instead of relying on a single raw request
- rate-limit crash-prone or resource-intensive probes and keep them opt-in
- calibrate templates against vulnerable, fixed, and negative-control targets
When scaling, separate fingerprinting from exploitation. Presence can prioritize assets; it does not confirm the vulnerability.
## Exploitability Triage
Rate each condition explicitly:
- attacker position and credentials
- default vs optional feature/configuration
- internet-facing vs auxiliary/local listener
- data/byte/control precision
- restart, race, victim action, or environment requirements
- available mitigations and architecture
- reliable primitive vs crash-only or unstable behavior
- practical post-primitive chain in the product's default deployment
Down-rate unrealistic chains even when the underlying bug is real. Conversely, revisit “low” primitives such as SSRF, reflection, arbitrary write, cache control, or information disclosure in product context; native admin features may convert them into RCE.
## Validation Deliverable
Include:
1. exact affected/fixed artifacts and hashes
2. authoritative published claims and unresolved ambiguity
3. minimal relevant diff and restored invariant
4. external route/protocol and auth/config prerequisites
5. source-to-sink or packet-to-sink trace
6. safe reproducer plus positive and negative controls
7. vulnerable vs fixed results across repeat runs
8. exploitability constraints and why the demonstrated impact follows
9. adjacent paths reviewed and any partial-fix evidence
## Anti-Patterns
- Trusting the advisory CWE/title as the actual root cause
- Diffing only application code while ignoring edge/proxy/service configuration
- Treating any crash, close, 500, scanner alert, or changed function as exploitation
- Running a weaponized public PoC before isolating its stages and side effects
- Claiming pre-auth impact without tracing the complete auth and routing path
- Assuming one CVE maps to one code path or one patch fixes the whole vulnerability class
- Searching only for the published payload instead of the restored invariant
- Reporting a registry/download/callback signal without separating automated noise from authentic target execution
- Generalizing from one appliance/version/configuration without testing prerequisites
## Summary
Advisory-driven research is evidence-driven reverse engineering. Acquire comparable artifacts, reduce the diff to a security invariant, prove external reachability, climb a safe reproducer ladder, calibrate against fixed and negative controls, and then audit sibling paths and partial fixes. The reusable output is the method and invariant—not the vendor-specific exploit string.
@@ -0,0 +1,176 @@
---
name: protocol-reverse-engineering
description: Authorized analysis of undocumented, proprietary, binary, or stateful network protocols using passive captures, client/server artifacts, explicit state machines, bounded lab harnesses, and semantic vulnerable-versus-fixed validation
---
# Protocol Reverse Engineering
Use this skill when an exposed service cannot be tested correctly as isolated HTTP-like requests: custom RPC, binary framing, TLS-wrapped management protocols, message queues, VPN negotiation, in-band control records, or any protocol whose authentication and parsing depend on prior state.
The objective is a reviewable protocol model and controlled evidence that proves or disproves a security property. A socket connection, completed TLS handshake, `200`, or parser crash does not prove authentication, authorization, or code execution.
## Authorization and Safety Boundary
- Work from supplied artifacts, offline captures, or an isolated lab target unless active testing is explicitly authorized.
- Prefer offline parsing. Captures may contain credentials, session material, personal data, or private topology; minimize, encrypt, redact, and expire them.
- Never replay production credentials or captured authentication material.
- Put active harnesses in a network namespace or isolated VLAN with an explicit destination allowlist, low rate, bounded retries, and one mutation at a time.
- Do not broadcast, scan unrelated addresses, or start mutation/fuzz loops by default.
- Treat a malformed-packet crash as a denial-of-service test. Perform it only in a restartable lab and never infer RCE from it.
## Build the Protocol Model
Record each layer separately:
| Layer | Questions |
|---|---|
| Transport | TCP, UDP, HTTP tunnel, queue, Unix socket, reconnect behavior? |
| Security | TLS/mTLS, certificate role, message MAC/signature, encryption boundary? |
| Framing | magic, version, type, flags, length, checksum, terminator, nesting? |
| State | negotiation, challenge, authentication, session, command, teardown? |
| Identity | where is peer/user/device identity introduced and verified? |
| Authorization | which state or role permits each operation? |
| Data model | integers, strings, TLV, XML/JSON, compression, serialization? |
| Responses | acknowledgements, errors, correlation IDs, timing, connection close? |
Maintain a message-field ledger:
```text
offset/path | size/type | endian/encoding | producer | consumer | validation | state | confidence
```
Label every statement as observed, inferred, or experimentally confirmed. Unknown bytes remain unknown; do not name them after a single sample.
## Workflow
### 1. Collect Passive Evidence
Use, in order of preference:
- official protocol or integration documentation
- offline captures of a legitimate client/server exchange
- client binaries, SDKs, schemas, constants, error strings, and debug logs
- server handlers, dispatch tables, configuration, and certificate logic
- vulnerable/fixed captures or binaries from the same branch
Use two supplied or explicitly authorized successful sessions and controlled variations when available. Otherwise record the evidence gap; do not obtain or replay production credentials merely to complete the model. Compare message boundaries, counters, nonces, lengths, identity fields, and state-dependent responses. Keep the original capture immutable and hash it.
Use [TShark](https://www.wireshark.org/docs/man-pages/tshark.html) for reproducible offline extraction:
```bash
tshark -r session.pcapng -q -z conv,tcp
tshark -r session.pcapng -Y 'tcp.stream == 0' -T fields \
-e frame.number -e tcp.seq -e tcp.len -e tcp.payload
```
Prefer `-r` over live capture. Do not run Wireshark/TShark as root, capture unrelated production traffic, or assume dissector output is safe or correct; use a patched build in an isolated environment for hostile captures.
### 2. Reconstruct Framing Before Meaning
- Reassemble streams before assigning message boundaries; TCP packets are not application messages.
- Test length hypotheses against multiple messages and both directions.
- Identify byte order, signedness, alignment, padding, compression, and checksums.
- Separate outer transport/tunnel framing from the inner application message.
- For nested formats, model each parser boundary independently.
- Reject impossible lengths before allocation, recursion, decompression, or slicing.
When the layout stabilizes, encode it in a declarative grammar such as [Kaitai Struct](https://kaitai.io/). Add `valid` constraints and strict size/count limits; generated parsers can still allocate or recurse dangerously on hostile lengths. Keep compiler/runtime versions aligned and regression-test the grammar on positive, truncated, oversized, and unknown-type samples.
### 3. Recover the State Machine
Write transitions explicitly:
```text
DISCONNECTED -> TRANSPORT -> NEGOTIATED -> PEER_VERIFIED
-> USER_AUTHENTICATED -> AUTHORIZED -> OPERATION
```
For every transition, record:
- initiating message and required prior state
- server-side check and identity source
- success, denial, and malformed responses
- state stored across messages or reconnects
- timeout/replay/counter behavior
- whether an alternate message type reaches the same handler
Distinguish transport establishment, peer verification, user authentication, session creation, role authorization, and successful privileged action. Prove the specific boundary relevant to the security claim.
### 4. Trace Fields to Decisions and Sinks
From binaries or source, anchor on message IDs, error strings, constants, certificate handling, dispatcher tables, and changed functions. Trace attacker-controlled fields through:
- length arithmetic, allocation, copy, termination, and integer conversion
- parser state, tag nesting, recursion, and unknown-field behavior
- identity selection, trust flags, signature/certificate verification, and session lookup
- shell/process calls, filesystem paths, deserialization, reflection, or product-native admin operations
Decompiler output is a hypothesis. Confirm important conditions in assembly, bytecode, runtime logs, or controlled packet results.
### 5. Build a Bounded Active Harness
Only craft packets after valid framing and state are understood. [Scapy](https://scapy.readthedocs.io/en/stable/) is appropriate for packet layers and stateful automata:
```bash
python -m pip install 'scapy==<reviewed-version>'
```
Start with a local responder or replay parser, not the appliance. Preserve a known-good transcript, mutate one semantic field, recompute dependent lengths/checksums, and compare the response. The harness must enforce:
- exact destination/port allowlist
- one target and one mutation by default
- rate, packet count, response size, timeout, and retry ceilings
- no broadcast/multicast and no automatic crash retry
- artifact logging without credentials or secret payloads
- cleanup and target health check after each risky case
Raw sockets may require privilege; isolate socket creation and drop privileges afterward where possible.
### 6. Design Semantic Experiments
Prefer experiments that answer one question:
- Does an invalid identity or signature reach the authorized state?
- Does a declared length govern copying, parsing, or only framing?
- Do duplicate/unknown fields change the selected handler?
- Does patched behavior add validation, change state, or block an outer route?
- Does a response prove the operation, or merely that dispatch began?
Use vulnerable, fixed, and malformed-negative controls. Repeat enough to separate deterministic semantics from loss, retransmission, process restart, load balancing, and timeout noise.
## Safe Oracles
Prefer, from least to most invasive:
1. distinctive protocol/version field
2. deterministic denial-versus-accept response
3. synthetic-account no-op or non-secret lab read
4. unique constant callback through explicitly authorized, preferably self-hosted OAST
5. inert canary write with cleanup
6. process execution only under separate explicit authorization when no lower-harm oracle can establish the required impact
A connection close is normally an ambiguous result. If crash validation is unavoidable, combine lab-only process logs, restart evidence, and a non-triggering control; report bug existence separately from exploitability.
When the starting point is an advisory, fixed build, patch, or public PoC, pair this skill with `advisory_to_poc` for evidence classification, artifact comparison, and partial-fix review.
## Patch and Version Differentials
- Compare message/state behavior across the closest vulnerable and fixed builds of the same branch.
- Derive a fingerprint from the restored invariant, not only from banners.
- Check configuration, certificate role, feature enablement, architecture, and deployment mode.
- Treat protocol differences as version evidence unless they directly prove vulnerable behavior.
- When one handler is patched, enumerate sibling message types, alternate transports, and pre-auth dispatch paths using the same parser or decision.
## Validation Deliverable
Include:
1. target versions, platform, configuration, and artifact/capture hashes
2. layered protocol diagram and message-field ledger
3. explicit state machine and identity/authentication/authorization boundaries
4. source/binary trace for the relevant field and decision
5. bounded harness with rate/destination safeguards
6. vulnerable, fixed, and negative-control results
7. minimum safe oracle and any side effects/cleanup
8. unresolved fields, assumptions, and confidence levels
9. bug-existence versus exploitability assessment
@@ -0,0 +1,253 @@
---
name: appliance-firmware
description: Security analysis of appliances and firmware through artifact provenance, safe extraction, root filesystem and runtime mapping, listener and trust-boundary inventory, patch comparison, managed/native code triage, hardware constraints, and isolated device validation
---
# Appliance and Firmware Analysis
Use this skill for VPNs, firewalls, storage/backup systems, management appliances, embedded products, virtual appliances, and other packaged systems where security behavior is split across firmware, web-server configuration, native daemons, scripts, managed services, generated state, and hardware-specific runtime details.
Appliance research is architecture research. The public web UI is only one entry point; auxiliary listeners, localhost APIs, sidecars, support agents, update services, telemetry jobs, package installers, and product-native administration features often carry equal or greater authority.
## Build and Artifact Matrix
Record before comparing anything:
| Dimension | Examples |
|---|---|
| Product | model/SKU, physical/virtual/cloud image, edition/license |
| Software | marketing version, build/revision, branch, hotfix, package set |
| Platform | architecture, endian, kernel, libc, bootloader, filesystem |
| Install state | factory image, upgraded system, migrated config, retained files |
| Configuration | feature flags, listeners, authentication mode, HA/cluster role |
| Artifact source | vendor download, updater, installed disk, backup, marketplace |
| Update form | full image, delta package, component hotfix, rollback bundle |
| Authenticity | signature/encryption state, certificate/key ID, manifest/base-version requirement |
Hash original artifacts and preserve acquisition metadata. A neighboring version from a different SKU, edition, architecture, or installation lineage can produce a convincing but irrelevant diff.
## Safe Extraction
Treat firmware and every embedded archive/filesystem as hostile input. Extract as an unprivileged user into a fresh writable quota-limited output directory with no network, bounded recursion/processes, and read-only input.
### unblob
[unblob](https://github.com/onekey-sec/unblob) provides recursive extraction plus structured metadata for many firmware/container/filesystem formats. Prefer a reviewed container image digest:
```bash
appliance_out="$(mktemp -d)"
docker run --rm --network none \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--user "$(id -u):$(id -g)" --pids-limit 256 --memory 4g --cpus 2 \
--tmpfs /tmp:rw,noexec,nosuid,size=512m \
-v /path/to/input:/data/input:ro \
-v "$appliance_out":/data/output \
ghcr.io/onekey-sec/unblob@sha256:<reviewed-digest> \
-e /data/output -d 6 -p 2 --report /data/output/unblob.json \
/data/input/firmware.bin
```
Create the output directory first and ensure it is writable by the chosen UID/GID; otherwise the host may create a root-owned mount point. Never extract over an existing analysis tree. Inspect symlinks, device nodes, archive paths, decompression ratios, and output size before interacting with the tree.
### diffoscope
Use [diffoscope](https://diffoscope.org/) for a recursive format-aware first comparison of vulnerable/fixed directories, packages, images, JARs, and executables:
```bash
diffoscope --html diffoscope.html vulnerable-root/ fixed-root/
```
Run it in an isolated reviewed container when processing hostile artifacts because it invokes many external format helpers. Use the first report to narrow files/config/packages rather than repeatedly expanding the entire image.
Use the unblob report and packaged filesystem metadata for ownership, mode, xattr, capability, and device-node claims; a host extraction run under your own UID can intentionally remap them. Do not mount an untrusted extracted filesystem or `chroot` into it on the analyst host.
## Filesystem and Boot Architecture
Inventory:
- partition table, bootloader, kernel, initramfs, SquashFS/UBIFS/ext filesystems
- init system, service definitions, inetd/socket activation, rc scripts, supervisors, and watchdogs
- read-only base image versus writable overlay, tmpfs, bind mounts, containers/chroots, and persistent data partitions
- factory defaults, first-boot generation, upgrade/migration scripts, rollback slots, and retained legacy files
- environment files, credentials, certificates, secrets, licenses, databases, sessions, caches, and backup/restore formats
- cron/timers, log rotation, telemetry, diagnostics, update checks, package deployment, support bundles, and cleanup tasks
- ownership, group membership, capabilities, setuid/setgid, ACLs, sudo/doas rules, device access, and IPC permissions
Static extracted files may not match runtime. Boot-time scripts can patch files, mount overlays, generate configs, copy certificates, activate routes, or replace binaries. Capture live filesystem/mount/process state when an apparently relevant change is absent from the disk image.
## Update and Installed-State Reconstruction
Before trusting a package or image diff, reconstruct how the device installs it:
- verify signature and manifest order, trust anchors, and whether integrity/authenticity checks cover the whole payload or only a wrapper
- distinguish full image, delta update, component hotfix, and required base version
- identify target partition, boot slot, rollback path, and anti-rollback/version checks
- review pre/post-install hooks, migrations, symlink changes, permission/capability changes, and retained/generated state
- map overlay, bind-mount, and generated-file precedence over the extracted rootfs
- test fresh install versus upgraded and partially rolled-back states
- reconcile package contents with hashes/build IDs from the actual running process and live filesystem
Record package-manager databases, shipped SBOM/manifests, bundled library copies, loader path, and `RPATH`/`RUNPATH` so you can distinguish a vulnerable library on disk from the library the running process actually maps.
## Listener and Service Map
Build a table for every network and local endpoint:
```text
address/port/socket | transport/TLS | process | config/init source
route/message type | authentication | authorization | privilege | feature/default
```
Include:
- HTTP(S) UI/API, CGI/FastCGI, WebSocket, SOAP, SAML/OIDC, upload/download
- SSH/SFTP, VPN/IKE, message queues, databases, backup/storage protocols
- proprietary TLS/RPC, cluster/HA, device-manager, agent, and telemetry ports
- loopback/Unix sockets, localhost APIs, sidecars, containers, and debug/support agents
- outbound update/download endpoints and trusted remote control planes
For outbound updater, telemetry, licensing, or control-plane names, record authoritative DNS/ownership, TLS identity and pinning, proxy/fallback behavior, request data, failure behavior, manifest integrity, payload integrity, rollback/version policy, and whether the external domain, bucket, package, or provider resource can expire or be reassigned.
Map edge configuration to code: reverse-proxy rules, rewrites, location blocks, authentication modules, trusted client-IP headers, TLS client certificates, and backend socket selection. A handler can be patched while a new edge rule merely hides it—or vice versa.
## Trust and Authorization Boundaries
Trace:
```text
external listener -> proxy/config -> router/dispatcher -> authentication
-> parser -> privileged operation -> OS/service identity
```
Test conceptual boundaries such as:
- public versus management interface
- external versus localhost/sidecar trust
- managed device versus manager/controller trust
- cluster peer, certificate, flag, or registration state
- web user versus OS/service/database authentication
- direct route versus internal redirect/component dispatch
- fresh install versus upgraded/retained installation state
- optional feature disabled versus installed-but-reachable handler
Successful TCP/TLS/WebSocket negotiation proves transport reachability, not authenticated identity or authorization. Determine the actual privileged result and which server-side flag/session/role enabled it.
## Code and Configuration Triage
### Scripts and Configuration
- Trace Apache/nginx/lighttpd rules, CGI mappings, environment variables, and shell/Perl/Python/PHP scripts.
- Search command construction beyond obvious shell metacharacters: arithmetic expansion, config files, response files, argument injection, newline/control characters, and third-party CLI parsing.
- Inspect support/debug functions, backup/restore, package install, log/telemetry processors, custom tags/templates, and native admin command runners.
- Compare configuration and init/upgrade changes alongside application code.
### Java/JVM and .NET
- Use [Vineflower](https://github.com/Vineflower/vineflower) for Java class/JAR reconstruction and `javap -c` to confirm ambiguous bytecode.
- Use official [ILSpy/ilspycmd](https://github.com/icsharpcode/ILSpy) for .NET assemblies and inspect IL/metadata when reconstructed C# is ambiguous.
- Do not build or run decompiler output, target assemblies/classes, bundled build scripts, or embedded resources in their associated target runtimes/viewers.
- Diff class/resource inventories before decompiled text to separate compiler/obfuscator noise from semantic changes.
### Native Binaries
- Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for strings/imports/xrefs/decompilation and reproducible headless projects.
- Use [BinDiff](https://github.com/google/bindiff) after manifest/package triage isolates the relevant native binaries, and keep the disassembler/BinExport version pair compatible across both sides.
- Confirm changed length, auth, command, parser, and file-handling conditions in assembly/runtime; decompiler types and similarity scores are hypotheses.
- Record architecture-specific calling convention, endian, alignment, libc, allocator, and mitigations.
Load `memory_corruption` for bounds/lifetime/disclosure findings and exploitability analysis. Load `protocol_reverse_engineering` for custom/stateful message formats.
## Version and Patch Analysis
Compare more than one adjacent pair when possible:
```text
older unaffected/unknown -> vulnerable -> first fixed -> current
```
- Build changed-file/package/config manifests first.
- Identify the security invariant introduced by the patch.
- Review every caller/sibling handler using the patched helper/parser.
- Check branch backports and inconsistent fixes across SKUs/architectures.
- Re-test the old structural condition on the fixed build and nearby routes.
- Inspect boot/runtime overlays and upgrade scripts if static diff shows no meaningful change.
- Distinguish one CVE from one code path; advisories may bundle several bugs or fix only the most exposed route.
Pair with `advisory_to_poc` for evidence classification, public-PoC decomposition, vulnerable/fixed controls, and detector handoff.
## Hardware, Virtualization, and Emulation
Record what the test environment omits:
- hardware security module/TPM/secure element and device-bound keys
- NIC/accelerator/driver behavior, DMA, endian/alignment, and kernel modules
- boot chain, secure boot, verified partitions, recovery mode, watchdog, and HA peer
- model-specific memory, allocator pressure, process limits, and service configuration
- virtual appliance differences from physical products
Full-system emulation can help recover routes and protocol behavior but often changes drivers, timing, entropy, memory layout, certificates, hardware identity, and mitigations. Treat emulation results as a separate platform and reproduce security-relevant behavior on the actual supported model when the claim depends on those properties.
Do not disable ASLR, canaries, signature checks, or other mitigations without labeling the resulting demonstration as lab-only and nonrepresentative of default exploitability.
## Physical-Lab Prerequisites
Have a recovery path before live-device work:
- console, serial, hypervisor, snapshot, or other known-good rollback method
- exact in-scope image/build and a way to reapply it
- isolated management network and controlled outbound connectivity
- process or watchdog visibility and a safe way to capture one request at a time
## Runtime Observation
Within an authorized lab, collect:
- process tree, executable/build ID, argv, cwd, users/groups/capabilities, open ports/sockets/files, mounts, namespaces/containers
- service logs, audit logs, core files, watchdog/restart events, and packet captures
- loaded mappings/libraries, relevant Unix sockets/file descriptors, and config source while sending one known request
- filesystem/process events while sending one known request
- boot/upgrade output and live configuration generated from templates/databases
Prefer observation that explains a static hypothesis. Do not install intrusive agents or attach a debugger to production equipment.
## Capability and Chain Mapping
Treat findings as product-context primitives:
- file read → configs, sessions, credentials, tokens, keys, topology
- SSRF/request → loopback APIs, sidecars, metadata, package agents
- file write → web roots, plugins, templates, restore packages, jobs, telemetry inputs
- auth bypass → support/admin command runners, package deployment, native operations
- parser disclosure → session/token/pointer material
- low-privilege identity → built-in management tools and trusted peer relationships
Inventory native product consumers before importing a generic exploit gadget. An appliance's normal backup, restore, diagnostic, package, scripting, or cluster function is frequently the shortest bridge between primitives.
## Deliverable
Include:
1. artifact provenance/hashes and complete SKU/version/platform/config matrix
2. extraction method and filesystem/boot/runtime architecture
3. listener/service/auth/trust-boundary map
4. changed-file/config/package manifest and relevant code path
5. external route/protocol through privileged operation and OS identity
6. hardware/emulation/mitigation constraints
7. vulnerable/fixed/negative-control behavior
8. adjacent handlers/branches/install states reviewed
9. tool versions, generated artifacts, and unresolved assumptions
## Common Errors
- Diffing different SKUs/architectures and attributing packaging noise to a security fix.
- Assuming extracted rootfs equals live state despite overlays, generation, or boot-time patches.
- Mapping only the web UI and missing auxiliary/custom/local listeners.
- Treating a hidden route as removed or a blocked route as a patched sink.
- Assuming fresh-install behavior covers upgraded systems with retained files/configuration.
- Calling a service pre-auth because a connection succeeds before a privileged operation is attempted.
- Treating emulator-only behavior or disabled mitigations as representative of a shipping device.
- Running an analyzed binary, extension, build script, or firmware helper on the analyst host.
## Summary
Appliances are integrated systems, not single applications. Preserve artifact lineage, extract safely, map boot/runtime state and every listener, trace edge configuration into code and privileged native features, compare fixes across branches and install states, and keep hardware/platform constraints attached to every finding.
@@ -0,0 +1,228 @@
---
name: memory-corruption
description: Native memory-safety analysis for stack and heap overflows, out-of-bounds access, uninitialized memory, use-after-free, integer and signedness errors, format strings, crash triage, exploitability constraints, and controlled lab validation
---
# Memory Corruption
Use this skill for authorized analysis of native parsers, network services, firmware daemons, libraries, and mixed web/native components where attacker-controlled bytes may violate memory safety.
Separate three questions throughout the work:
1. **Bug existence:** does an input cause an invalid read, write, lifetime violation, or disclosure?
2. **Primitive quality:** what bytes, address, length, timing, or object state can the attacker control or observe?
3. **Exploitability:** can that primitive bypass the target architecture, mitigations, allocator, protocol, and restart constraints?
A crash, connection close, watchdog restart, or sanitizer report proves neither instruction-pointer control nor RCE.
## Lab Boundary
Malformed-input and crash work is denial-of-service testing. Run it only against an explicitly authorized, restartable lab target with console/process visibility, health checks, rate ceilings, and a recovery procedure. Do not fuzz production services or automatically replay crash cases.
Analyze hostile binaries, cores, packet captures, and corpora inside an isolated environment. Do not execute an unknown sample merely because a debugger or decompiler imported it.
## Vulnerability Classes
### Bounds and Length Errors
- fixed destination with attacker-controlled copy/format length
- allocation based on one length and copy based on another
- off-by-one termination or delimiter handling
- nested length fields and cumulative-size overflow
- stack/heap out-of-bounds read or write
- negative length converted to unsigned, truncation between integer widths, or multiplication/addition overflow
- encoded/decoded/compressed size disagreement
### Initialization and Termination
- uninitialized stack/heap data returned in a response
- reused object/buffer retaining data from another request or tenant
- missing NUL termination followed by string length/format operations
- partial structure initialization with stale flags, pointers, or lengths
- padding, union, or serialization bytes copied beyond initialized fields
### Lifetime and Object Confusion
- use-after-free, double free, stale callback, iterator invalidation
- type/object confusion after parsing, casting, or virtual dispatch
- reference-count races and cross-thread ownership errors
- reallocation invalidating stored pointers
- constructor/destructor/finalizer behavior reached in an unexpected state
### Format and Variadic Errors
- attacker-controlled format string
- type/width mismatch in variadic arguments
- destination-size assumptions around `sprintf`-family calls
- logging/error paths that process attacker bytes after a partial parse
## Build the Input-to-Memory Model
Record:
```text
transport field -> parser type/width -> normalized value -> allocation
-> copy/read/format operation -> object/buffer -> later use
```
For each relevant field, capture:
- wire offset/path, endian, encoding, signedness, and declared versus actual size
- validation order and parser state required to reach the operation
- allocation expression and destination capacity
- copy/read/write expression and implicit casts
- terminator/padding/alignment behavior
- attacker-controlled byte alphabet and precision
- thread, connection, session, heap, and restart lifetime
Trace both source-to-sink and sink-to-source. Start from changed bounds checks or crash instructions when available, but reconstruct the minimum valid protocol state that reaches them.
## Source-Available Workflow
### Compiler Instrumentation
Build a lab-only target or minimal harness with the compiler's maintained sanitizers when source permits:
```bash
clang -g -O1 -fno-omit-frame-pointer \
-fsanitize=address,undefined \
harness.c parser.c -o parser-harness
```
- Keep the harness local and networkless; call the narrow parser/API directly.
- Preserve the exact compiler, flags, architecture, allocator, and dependencies.
- AddressSanitizer changes layout and timing. Reproduce important behavior on a representative unsanitized build under a debugger before drawing exploitability conclusions.
- UndefinedBehaviorSanitizer may report conditions that do not produce the deployed security impact; trace each report to attacker control and later use. It does not replace explicit arithmetic and cast review.
- For ordinary uninitialized-value hypotheses, use a separate MemorySanitizer build such as `-fsanitize=memory -fsanitize-memory-track-origins=2`; it requires an instrumented dependency set and is not interchangeable with ASan.
- For race-dependent ownership or refcount paths, use a separate ThreadSanitizer build only when concurrency is in scope; do not imply the sanitizer families compose cleanly into one representative build.
- Add regression cases for the minimized triggering input and neighboring non-triggering controls.
### Static Review
Search around input parsing for:
- `memcpy`, `memmove`, `strcpy`, `strcat`, `sprintf`, `snprintf`, `scanf` families
- manual cursor/end-pointer arithmetic and nested TLV/XML/string parsers
- `malloc/calloc/realloc/new` size arithmetic
- signed/unsigned conversions and narrowing casts
- length values stored in smaller fields or reused across decoded representations
- error cleanup, ownership transfer, callbacks, and asynchronous lifetime
- custom allocators, pools, slabs, ring buffers, and request-buffer reuse
Do not report a dangerous function name without proving attacker control, reachable state, capacity mismatch, and the actual deployed implementation.
## Binary-Only Workflow
1. Identify architecture, endian, ABI, OS/libc, compiler clues, and stripped/symbol state.
2. Record NX/DEP, ASLR/PIE, stack canaries, RELRO, CFI/PAC/CET, allocator hardening, seccomp/sandbox, privilege, and restart behavior.
3. Anchor on imports, strings, message IDs, error paths, new checks, crash PC, or advisory-relevant constants.
4. Trace length/copy/allocation dataflow in decompiler and assembly.
5. Record the deployed binary identity: build ID or hash, interpreter or loader, loaded modules/base addresses, allocator, and whether the runtime executable came from base image, overlay, bind mount, or update staging.
6. Reproduce under a debugger or emulator only when its environment matches the relevant parser and allocator behavior.
7. Compare vulnerable and fixed functions; describe the restored invariant and inspect sibling callers.
Use official [Ghidra](https://github.com/NationalSecurityAgency/ghidra) for cross-architecture static analysis and [BinDiff](https://github.com/google/bindiff) for function-level version comparison after package/file diffs narrow the target. Similarity scores and decompiled C are triage aids, not proof; confirm critical conditions in assembly and runtime evidence.
## Crash and Disclosure Triage
Preserve one known-good transcript and then minimize while keeping the framing, checksums, parser state, and negotiation required to reach the vulnerable operation. Identify the first invalid access, not only the eventual crash site. Use a distinctive non-executable pattern to measure overwrite offset or disclosure position, classify whether the observed effect is read, write, non-control-data, pointer/object, or control-state influence, and then repeat the same case on a representative unsanitized build plus fixed and negative controls.
For each case, record:
- exact minimized input and protocol transcript
- deterministic frequency and required heap/session preparation
- signal/exception, PC, faulting instruction bytes/disassembly, fault address, access type/size, registers, stack, loaded mappings/build IDs, and relevant object memory
- process versus worker crash, watchdog/restart, and external symptom
- corrupted object provenance and last known-valid parser state
- vulnerable/fixed/unaffected build behavior
- whether the same case under debugger/sanitizer changes outcome
Deduplicate by root cause, not only crash address. One overwrite may crash at many later consumers; one parser family may contain multiple distinct missing checks.
For disclosures, classify the returned bytes:
- predictable padding or constant data
- same-request content
- cross-request/tenant secrets
- heap/stack pointers useful against ASLR
- session tokens, keys, credentials, or application data
Derive detectors from response structure or a constant non-secret marker rather than collecting sensitive memory.
## Primitive Analysis
### Write Primitive
- location: fixed, relative, attacker-derived, heap-neighbor, object field, return/control data
- width and count: single byte/bit, bounded span, arbitrary length, repeated writes
- value control: exact, restricted alphabet, additive, terminator, pointer-derived
- timing/state: before validation, after free, race-dependent, heap-shape-dependent
- repeatability under default allocator and mitigations
### Read/Leak Primitive
- offset and length control
- termination rules and response encoding
- ability to repeat/advance across memory
- cross-request process reuse
- pointer or secret classification
- noise, truncation, and crash threshold
### Control-Flow/Object Primitive
- overwritten callback, vtable, length, non-control-data flag, pointer, credential/session reference, allocator metadata, saved return state, or interpreter structure
- required heap grooming/object placement
- available modules/gadgets and address disclosure
- thread/process privilege and sandbox boundary after control
- whether the attacker can only corrupt a field, or can also choose the dereference target and value later consumed
Document what remains constrained. “Arbitrary write” should not be used for a relative, partial, alphabet-limited, or race-only overwrite.
## Exploitability Matrix
| Dimension | Record |
|---|---|
| Reachability | listener, authentication, feature/config, valid prior state |
| Platform | architecture, endian, ABI, firmware model/SKU |
| Input | transport, maximum size, forbidden bytes, encoding/transforms |
| Primitive | read/write/control precision, repeatability, heap dependence |
| Mitigations | ASLR/PIE, NX, canary, RELRO, CFI/PAC/CET, allocator, sandbox |
| Process | privilege, chroot/container, worker isolation, watchdog/restart |
| Information | version fingerprint, pointer/module/heap leak availability |
| Reliability | attempts, races, connection/session persistence, crash side effects |
Rate exploitability separately from bug severity. A strong memory disclosure can enable a later control-flow bug; a large overflow may remain crash-only under the deployed constraints.
## Protocol and Patch Pairing
- Load `protocol_reverse_engineering` when valid negotiation/state is required before the vulnerable field.
- Load `advisory_to_poc` for vulnerable/fixed artifact matrices and patch-invariant review.
- Load `appliance_firmware` for rootfs, listener, runtime overlay, architecture, and device lifecycle mapping.
- Model transformation boundaries explicitly when the memory length or type changes across transport, parser, decoder, or native FFI layers.
## Validation Deliverable
Include:
1. exact vulnerable/fixed build, platform, configuration, and artifact hashes
2. minimized input plus complete protocol/parser prerequisites
3. source, IR/bytecode, or assembly trace from attacker field to invalid access, with the exact crashing process/build identity
4. debugger/sanitizer/core evidence and non-triggering control
5. primitive precision and constraints
6. mitigation, architecture, allocator, process, and restart analysis
7. bug-existence and exploitability conclusions stated separately
8. adjacent callers/parser family reviewed
## False Positives
- Connection close caused by protocol rejection, idle timeout, rate limit, or load balancer behavior.
- Process restart inferred from one failed request without process/console evidence.
- Sanitizer finding unreachable in the deployed feature, route, architecture, or configuration.
- Out-of-bounds read that returns only deterministic in-buffer padding, described as sensitive disclosure.
- Crash-only overwrite called RCE without a controlled data/control primitive and mitigation analysis.
- Decompiler type or buffer size accepted as ground truth without assembly/runtime confirmation.
- Lab build with mitigations disabled presented as representative of production.
## Summary
Memory-corruption research is constraint analysis. Trace exact bytes through length, allocation, copy, object lifetime, and later use; establish the read/write/control primitive; then evaluate architecture, mitigations, allocator, protocol, and process context independently from the mere existence of a crash.
-21
View File
@@ -1,21 +0,0 @@
"""Generic MCP client: connect MCP servers and expose their tools."""
from __future__ import annotations
from strix.tools.mcp.client import ConnectedMcpServer, connect_mcp_servers
from strix.tools.mcp.config import (
BearerAuth,
McpAuth,
McpConnectionConfig,
)
from strix.tools.mcp.loader import load_user_mcp_configs
__all__ = [
"BearerAuth",
"ConnectedMcpServer",
"McpAuth",
"McpConnectionConfig",
"connect_mcp_servers",
"load_user_mcp_configs",
]
-348
View File
@@ -1,348 +0,0 @@
"""Connect to MCP servers and expose their tools to the agent.
Given one :class:`McpConnectionConfig` per server, :func:`connect_mcp_servers`
lists each server's tools, keeps the ones on the connection's allowlist (or all
of them when none is set), prefixes each with the connection name so servers do
not collide, and registers them through the agent factory. The factory applies
output bounding, per-call timeouts, and structured errors to every registered
tool, so this layer does not reimplement them.
A server that cannot connect, or a tool set that cannot be registered, is logged
and skipped, so one bad connection never fails the run.
"""
from __future__ import annotations
import contextlib
import json
import logging
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from agents.exceptions import ModelBehaviorError
from agents.mcp import (
MCPServer,
MCPServerStdio,
MCPServerStdioParams,
MCPServerStreamableHttp,
MCPServerStreamableHttpParams,
MCPUtil,
create_static_tool_filter,
)
from strix.agents.factory import register_agent_tools
if TYPE_CHECKING:
from collections.abc import Callable
from agents.tool import FunctionTool, Tool
from mcp.types import Tool as MCPTool
from strix.tools.mcp.config import McpConnectionConfig
# Runs on each tool's structured result before it reaches the agent. Called
# ``result_transform(namespaced_tool_name, structured_result)`` and its return
# value becomes the tool's output. ``structured_result`` is the parsed
# ``CallToolResult`` as a dict (not a serialized string), so the transform can
# project or drop individual fields.
ResultTransform = Callable[[str, Any], Any]
logger = logging.getLogger(__name__)
class ConnectedMcpServer(NamedTuple):
"""One successfully connected MCP server and how many tools it registered.
``server`` is kept so the caller can clean it up when the run ends;
``name`` and ``tool_count`` let the caller show the user a startup summary;
``notes`` carries the connection's optional free-text description so the
caller can surface it to the agent as context about the connection.
"""
server: MCPServer
name: str
tool_count: int
notes: str | None = None
def _auth_headers(config: McpConnectionConfig) -> dict[str, str]:
"""Build the per-server request headers from the connection's auth."""
auth = config.auth
if auth is None:
return {}
return {"Authorization": f"Bearer {auth.token}"}
def _build_server(config: McpConnectionConfig) -> MCPServer:
"""Construct (but do not connect) the SDK server for one connection.
When ``allowed_tools`` is a list the static filter means the server will not
even list tools outside it; :func:`_register_server_tools` re-applies the
same allowlist as the authoritative gate on what gets registered. When it is
``None`` no filter is applied and every listed tool is registered.
"""
tool_filter = (
create_static_tool_filter(allowed_tool_names=config.allowed_tools)
if config.allowed_tools is not None
else None
)
if config.transport == "stdio":
stdio_params: MCPServerStdioParams = {
"command": cast("str", config.command),
"args": config.args,
"env": config.env,
}
return MCPServerStdio(
params=stdio_params,
name=config.name,
tool_filter=tool_filter,
cache_tools_list=True,
)
http_params: MCPServerStreamableHttpParams = {
"url": cast("str", config.url),
"headers": _auth_headers(config),
}
return MCPServerStreamableHttp(
params=http_params,
name=config.name,
tool_filter=tool_filter,
cache_tools_list=True,
)
def _build_tool(
config: McpConnectionConfig,
server: MCPServer,
mcp_tool: MCPTool,
result_transform: ResultTransform | None,
) -> FunctionTool:
"""Build one namespaced FunctionTool from a listed MCP tool.
The SDK builds the tool (so name override, input schema, approval policy,
error-as-result handling, and tool-origin metadata are unchanged). With a
``result_transform`` we route the underlying MCP call through
:func:`_install_result_transform` so the transform sees the structured result
and decides the tool's output. Without one (the stock path), we still route
the call, through :func:`_install_error_status_capture`, so an errored result
reads as failed in the TUI while the agent's content is unchanged.
"""
namespaced_name = f"{config.name}.{mcp_tool.name}"
tool = MCPUtil.to_function_tool(
mcp_tool,
server,
convert_schemas_to_strict=False,
tool_name_override=namespaced_name,
)
if result_transform is not None:
_install_result_transform(tool, server, mcp_tool.name, namespaced_name, result_transform)
else:
_install_error_status_capture(tool, server, mcp_tool.name, namespaced_name)
return tool
def _install_result_transform(
tool: FunctionTool,
server: MCPServer,
base_tool_name: str,
namespaced_name: str,
result_transform: ResultTransform,
) -> None:
"""Route a tool's MCP call through ``result_transform``, innermost.
``MCPUtil.to_function_tool`` serializes the result inside its own invoke, so
the structured result cannot be intercepted through it. Instead we call
``server.call_tool`` ourselves, hand the parsed :class:`CallToolResult` to the
transform, and return the transform's output as the tool result.
This runs INSIDE the tool's invoke. The agent factory wraps a registered
tool's ``on_invoke_tool`` with output bounding, disk spill, and tracing at
agent-build time, which is OUTSIDE this invoke, so the transform is genuinely
the innermost step: nothing sees the raw result before the transform does.
``to_function_tool`` wraps the real invoke in the SDK's failure-handling
invoker, which stores the inner coroutine on ``_invoke_tool_impl`` and calls
it inside its try/except. Swapping that inner impl keeps the SDK's
error-as-result handling and all tool metadata while inserting the transform.
If the SDK ever renames that attribute we fail loudly rather than silently
skip the transform.
"""
async def _invoke(_ctx: Any, input_json: str) -> Any:
parsed: Any = json.loads(input_json) if input_json else {}
if not isinstance(parsed, dict):
raise ModelBehaviorError(
f"Invalid JSON input for tool {namespaced_name}: expected a JSON object"
)
args = cast("dict[str, Any]", parsed)
result = await server.call_tool(base_tool_name, args)
structured_result = result.model_dump(mode="json")
return result_transform(namespaced_name, structured_result)
_replace_tool_invoke(tool, _invoke)
def _replace_tool_invoke(tool: FunctionTool, invoke: Callable[[Any, str], Any]) -> None:
"""Swap a FunctionTool's inner invoke, failing loudly if the SDK shape changed.
``to_function_tool`` wraps the real invoke in the SDK's failure-handling
invoker, which stores the inner coroutine on ``_invoke_tool_impl`` and calls
it inside its own try/except. Swapping that inner impl keeps the SDK's
error-as-result handling and every piece of tool metadata intact. It is a
plain object with the coroutine as an attribute, not a function, so we treat
it as untyped to swap it. If the SDK ever renames that attribute we raise
rather than silently leave the swap un-applied.
"""
invoker = cast("Any", tool.on_invoke_tool)
if not hasattr(invoker, "_invoke_tool_impl"):
raise RuntimeError(
"agents SDK FunctionTool invoker shape changed: cannot swap the tool "
"invoke without risking it being silently skipped."
)
invoker._invoke_tool_impl = invoke
def _mcp_result_to_tool_output(server: MCPServer, result: Any) -> Any:
"""Serialize a ``CallToolResult`` to a tool output, mirroring the agents SDK.
This reproduces the serialization in ``agents.mcp.util.MCPUtil.invoke_mcp_tool``
(structured-content JSON when the server asks for it, otherwise text/image
content blocks, unwrapping a single block). Because the stock path now routes
its own call, this is what makes the agent see byte-identical content to what
the SDK would have produced on its own.
"""
if getattr(server, "use_structured_content", False) and result.structuredContent:
return json.dumps(result.structuredContent)
outputs: list[dict[str, Any]] = []
for item in result.content:
if item.type == "text":
outputs.append({"type": "text", "text": item.text})
elif item.type == "image":
outputs.append(
{"type": "image", "image_url": f"data:{item.mimeType};base64,{item.data}"}
)
else:
outputs.append({"type": "text", "text": str(item.model_dump(mode="json"))})
if len(outputs) == 1:
return outputs[0]
return outputs
def _install_error_status_capture(
tool: FunctionTool,
server: MCPServer,
base_tool_name: str,
namespaced_name: str,
) -> None:
"""Make an errored MCP result read as failed in the TUI, agent content unchanged.
The stock SDK invoke returns only the text/image tool output and drops the
``CallToolResult.isError`` flag, so the TUI cannot tell an errored MCP call
(which it renders as a green "done") from a successful one. We route the call
the same way :func:`_install_result_transform` does, read ``isError`` off the
full result, and on an error tag the returned output dict with
``success: False``.
That tag reaches the human-facing status but not the agent. The SDK stores the
raw return value on the run item's ``output`` (which the TUI reads to derive a
tool's status), but hands the agent the value re-projected through its
ToolOutput schema, which keeps only the known ``type``/``text`` fields and
drops the extra ``success`` key. So the status flips to failed while the agent
still receives exactly the same error content it does today. Non-error calls
return the stock output unchanged and keep rendering as done.
"""
async def _invoke(_ctx: Any, input_json: str) -> Any:
parsed: Any = json.loads(input_json) if input_json else {}
if not isinstance(parsed, dict):
raise ModelBehaviorError(
f"Invalid JSON input for tool {namespaced_name}: expected a JSON object"
)
args = cast("dict[str, Any]", parsed)
result = await server.call_tool(base_tool_name, args)
tool_output = _mcp_result_to_tool_output(server, result)
if getattr(result, "isError", False) and isinstance(tool_output, dict):
return {**tool_output, "success": False}
return tool_output
_replace_tool_invoke(tool, _invoke)
async def _register_server_tools(
config: McpConnectionConfig,
server: MCPServer,
result_transform: ResultTransform | None = None,
) -> list[Tool]:
"""List a connected server's tools, prefix + filter them, and register them.
``allowed_tools`` of ``None`` registers every listed tool; a list restricts
to exactly those names.
"""
allowed = config.allowed_tools
mcp_tools = await server.list_tools()
tools: list[Tool] = [
_build_tool(config, server, mcp_tool, result_transform)
for mcp_tool in mcp_tools
if allowed is None or mcp_tool.name in allowed
]
register_agent_tools(*tools)
return tools
async def connect_mcp_servers(
configs: list[McpConnectionConfig],
result_transform: ResultTransform | None = None,
) -> list[ConnectedMcpServer]:
"""Connect to each MCP server and register its tools.
When ``result_transform`` is given, every registered tool routes its result
through it before the result reaches the agent (see
:func:`_install_result_transform`). When it is ``None`` the tools behave
exactly as the SDK builds them.
Returns one :class:`ConnectedMcpServer` per server that connected, carrying
the SDK server (so the caller can clean it up when the run ends) plus the
server name and how many tools it registered (so the caller can show the
user a startup summary). Connections that fail are skipped rather than
raised.
"""
connected: list[ConnectedMcpServer] = []
for config in configs:
server: MCPServer | None = None
try:
server = _build_server(config)
await server.connect() # type: ignore[no-untyped-call]
tools = await _register_server_tools(config, server, result_transform)
except Exception:
logger.exception("Skipping MCP connection %r", config.name)
if server is not None:
with contextlib.suppress(Exception):
await server.cleanup() # type: ignore[no-untyped-call]
continue
except BaseException:
# A cancellation (or other non-Exception failure) mid-connect must not
# orphan MCP subprocesses or HTTP sessions. Clean up the server being
# connected and every server already connected, then re-raise so the
# caller still stops. The runner only receives the list on a clean
# return, so on an abnormal exit this function owns the cleanup.
if server is not None:
with contextlib.suppress(Exception):
await server.cleanup() # type: ignore[no-untyped-call]
for established in connected:
with contextlib.suppress(Exception):
await established.server.cleanup() # type: ignore[no-untyped-call]
raise
logger.info("Connected MCP server %r (%d tools)", config.name, len(tools))
connected.append(
ConnectedMcpServer(
server=server, name=config.name, tool_count=len(tools), notes=config.notes
)
)
return connected
-73
View File
@@ -1,73 +0,0 @@
"""The connection-config contract for the MCP client.
Describes one MCP server the client can connect to: its transport, endpoint or
launch command, optional auth, and an optional tool allowlist. Field names are
stable; callers build against them.
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class BearerAuth(BaseModel):
"""Header-token auth, sent as ``Authorization: Bearer <token>``."""
model_config = ConfigDict(extra="forbid")
kind: Literal["bearer"] = "bearer"
token: str = Field(min_length=1, repr=False)
McpAuth = Annotated[BearerAuth, Field(discriminator="kind")]
class McpConnectionConfig(BaseModel):
"""One MCP server the client can connect to.
Two transports are supported: streamable ``http`` (a remote endpoint) and
``stdio`` (a local server launched as a subprocess).
"""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1)
"""Namespaced tool prefix, unique per run (e.g. ``github``)."""
transport: Literal["http", "stdio"] = "http"
"""``http`` for a streamable HTTP endpoint, ``stdio`` for a local subprocess."""
url: str | None = Field(default=None, min_length=1)
"""The MCP server endpoint. Required for ``http``."""
auth: McpAuth | None = None
"""Bearer token for the server. Optional; a local stdio server usually
needs none."""
command: str | None = Field(default=None, min_length=1)
"""The executable to launch for ``stdio``. Required for ``stdio``."""
args: list[str] = Field(default_factory=list)
"""Arguments passed to ``command`` (stdio only)."""
env: dict[str, str] = Field(default_factory=dict)
"""Extra environment variables for the stdio subprocess."""
allowed_tools: list[str] | None = None
"""Tool allowlist, applied after the server lists its tools. ``None`` (the
default) exposes every tool the server lists; a list restricts to it."""
notes: str | None = None
"""Free-text notes for the agent describing what this connection is and how
to use it. When set, they are prefixed onto each of the connection's tool
descriptions so the agent sees them."""
@model_validator(mode="after")
def _check_transport_fields(self) -> McpConnectionConfig:
if self.transport == "http" and not self.url:
raise ValueError("an http MCP connection requires 'url'")
if self.transport == "stdio" and not self.command:
raise ValueError("a stdio MCP connection requires 'command'")
return self
-132
View File
@@ -1,132 +0,0 @@
"""Read the open-source user's MCP servers from ``~/.strix/mcp-servers.json``.
An open-source user lists the MCP servers they want the agent to reach in a
small JSON file. Strix reads it at the start of a run, connects to each server,
and registers its tools. The file is optional; without it the run simply gets
no MCP tools.
Parsing is fail-open. A single malformed entry is logged and skipped rather than
raising, so one bad row never blocks the servers that are valid, and a missing
or unreadable file yields an empty list.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import cast
from pydantic import ValidationError
from strix.tools.mcp.config import McpConnectionConfig
logger = logging.getLogger(__name__)
_DEFAULT_PATH: Path = Path.home() / ".strix" / "mcp-servers.json"
_PATH_ENV_VAR = "STRIX_MCP_CONFIG"
# Per-run selection, set by the --mcp-server / --mcp-exclude CLI flags. Each is a
# comma-separated list of connection names.
_ONLY_ENV_VAR = "STRIX_MCP_ONLY"
_EXCLUDE_ENV_VAR = "STRIX_MCP_EXCLUDE"
def _resolve_path(path: Path | None) -> Path:
if path is not None:
return path
override = os.environ.get(_PATH_ENV_VAR)
if override:
return Path(override)
return _DEFAULT_PATH
def _dedupe_by_name(configs: list[McpConnectionConfig]) -> list[McpConnectionConfig]:
"""Keep the first connection of each name, dropping later duplicates.
Names namespace a server's tools (``<name>.<tool>``), so two connections
sharing a name would collide and the second's tools would be silently
rejected at registration. Drop the duplicate here, with a warning, instead.
"""
seen: set[str] = set()
unique: list[McpConnectionConfig] = []
for config in configs:
if config.name in seen:
logger.warning(
"Ignoring MCP server %r: another connection already uses that name "
"(names must be unique because they namespace the server's tools).",
config.name,
)
continue
seen.add(config.name)
unique.append(config)
return unique
def _parse_names(env_var: str) -> set[str]:
return {name.strip() for name in os.environ.get(env_var, "").split(",") if name.strip()}
def _apply_run_selection(configs: list[McpConnectionConfig]) -> list[McpConnectionConfig]:
"""Restrict this run's connections to an optional include/exclude selection.
``STRIX_MCP_ONLY`` (if set) keeps only the named connections; then
``STRIX_MCP_EXCLUDE`` drops any named connection. With neither set, every
connection is kept.
"""
only = _parse_names(_ONLY_ENV_VAR)
exclude = _parse_names(_EXCLUDE_ENV_VAR)
if not only and not exclude:
return configs
available = {config.name for config in configs}
for name in sorted((only | exclude) - available):
logger.warning(
"MCP connection selection named %r, which is not configured; ignoring it", name
)
selected: list[McpConnectionConfig] = []
for config in configs:
if only and config.name not in only:
continue
if config.name in exclude:
continue
selected.append(config)
return selected
def load_user_mcp_configs(path: Path | None = None) -> list[McpConnectionConfig]:
"""Load MCP connection configs from the user's JSON file.
The path is ``path`` if given, else ``$STRIX_MCP_CONFIG``, else
``~/.strix/mcp-servers.json``. The file is a JSON list of server entries.
A missing file returns ``[]``; an unreadable or non-list file is logged and
returns ``[]``; individual entries that fail validation are logged and
skipped. Connections sharing a name are de-duplicated (first wins), and an
optional per-run include/exclude selection is applied last.
"""
source = _resolve_path(path)
if not source.exists():
return []
try:
raw = json.loads(source.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.exception("Could not read MCP config at %s; ignoring it", source)
return []
if not isinstance(raw, list):
logger.warning("MCP config at %s is not a JSON list; ignoring it", source)
return []
entries = cast("list[object]", raw)
configs: list[McpConnectionConfig] = []
for index, entry in enumerate(entries):
try:
configs.append(McpConnectionConfig.model_validate(entry))
except ValidationError as exc:
logger.warning("Skipping invalid MCP server entry #%d in %s: %s", index, source, exc)
return _apply_run_selection(_dedupe_by_name(configs))
-88
View File
@@ -1,88 +0,0 @@
"""Tests for the --mcp-config CLI flag."""
from __future__ import annotations
import importlib
import os
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
cli_main: Any = importlib.import_module("strix.interface.main")
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
cli_main,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
)
def test_mcp_config_flag_sets_loader_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = tmp_path / "servers.json"
config.write_text("[]", encoding="utf-8")
_stub_settings(monkeypatch)
# delenv records "originally absent" so monkeypatch removes whatever the
# parser sets, keeping the override from leaking into other tests.
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
monkeypatch.setattr(
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(config)]
)
args = cli_main.parse_arguments()
assert args.mcp_config == str(config)
assert os.environ["STRIX_MCP_CONFIG"] == str(config)
def test_mcp_config_flag_rejects_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_stub_settings(monkeypatch)
monkeypatch.delenv("STRIX_MCP_CONFIG", raising=False)
missing = tmp_path / "nope.json"
monkeypatch.setattr(
sys, "argv", ["strix", "-t", "https://test.com/", "-n", "--mcp-config", str(missing)]
)
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "--mcp-config file not found" in capsys.readouterr().err
def test_mcp_server_flags_set_selection_env(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_settings(monkeypatch)
monkeypatch.delenv("STRIX_MCP_ONLY", raising=False)
monkeypatch.delenv("STRIX_MCP_EXCLUDE", raising=False)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"https://test.com/",
"-n",
"--mcp-server",
"a",
"--mcp-server",
"b",
"--mcp-exclude",
"c",
],
)
cli_main.parse_arguments()
assert os.environ["STRIX_MCP_ONLY"] == "a,b"
assert os.environ["STRIX_MCP_EXCLUDE"] == "c"
-631
View File
@@ -1,631 +0,0 @@
"""Tests for the generic MCP client: config contract, namespacing, and filtering."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
import pytest
from agents.mcp import MCPServer, MCPServerStdio, MCPServerStreamableHttp
from mcp.types import CallToolResult, TextContent
from mcp.types import Tool as MCPTool
from pydantic import ValidationError
from strix.agents import factory
from strix.core.runner import _mcp_connection_notes
from strix.interface.tui.live_view import _tool_status_from_result
from strix.tools.mcp import (
BearerAuth,
ConnectedMcpServer,
McpConnectionConfig,
load_user_mcp_configs,
)
from strix.tools.mcp import client as mcp_client
from strix.tools.mcp.client import _auth_headers, _build_server, _register_server_tools
if TYPE_CHECKING:
from pathlib import Path
from agents.tool import Tool
class FakeMCPServer(MCPServer):
"""A connected MCP server stand-in, so tests never touch the network."""
def __init__(self, name: str, tools: list[MCPTool]) -> None:
super().__init__()
self._name = name
self._tools = tools
self.calls: list[tuple[str, dict[str, Any] | None]] = []
@property
def name(self) -> str:
return self._name
async def connect(self) -> None:
return None
async def cleanup(self) -> None:
return None
async def list_tools(
self,
run_context: Any = None,
agent: Any = None,
) -> list[MCPTool]:
return list(self._tools)
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
self.calls.append((tool_name, arguments))
return CallToolResult(content=[TextContent(type="text", text=f"routed:{tool_name}")])
async def list_prompts(self) -> Any:
raise NotImplementedError
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
raise NotImplementedError
def _mcp_tool(name: str) -> MCPTool:
return MCPTool(
name=name,
description=f"remote tool {name}",
inputSchema={"type": "object", "properties": {}},
)
def _config(name: str, allowed_tools: list[str]) -> McpConnectionConfig:
return McpConnectionConfig(
name=name,
url="https://mcp.example.com",
auth=BearerAuth(token="run-token"),
allowed_tools=allowed_tools,
)
@pytest.fixture(autouse=True)
def _reset_registry() -> Any:
saved = list(factory._EXTRA_TOOLS)
factory._EXTRA_TOOLS.clear()
try:
yield
finally:
factory._EXTRA_TOOLS[:] = saved
# --- config contract ---------------------------------------------------------
def test_bearer_config_parses_from_dict() -> None:
config = McpConnectionConfig.model_validate(
{
"name": "files_main",
"transport": "http",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
"allowed_tools": ["list_files"],
}
)
assert isinstance(config.auth, BearerAuth)
assert config.auth.token == "abc"
assert config.allowed_tools == ["list_files"]
def test_unknown_auth_kind_is_rejected() -> None:
with pytest.raises(ValidationError):
McpConnectionConfig.model_validate(
{
"name": "x",
"url": "https://mcp.example.com",
"auth": {"kind": "oauth", "token": "abc"},
}
)
def test_stdio_config_parses_from_dict() -> None:
config = McpConnectionConfig.model_validate(
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"],
"env": {"FOO": "bar"},
}
)
assert config.transport == "stdio"
assert config.command == "npx"
assert config.args == ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]
assert config.env == {"FOO": "bar"}
# A local stdio server needs no auth, and omitting allowed_tools means "all".
assert config.auth is None
assert config.allowed_tools is None
def test_http_config_without_url_is_rejected() -> None:
with pytest.raises(ValidationError):
McpConnectionConfig.model_validate(
{
"name": "x",
"transport": "http",
"auth": {"kind": "bearer", "token": "abc"},
}
)
def test_stdio_config_without_command_is_rejected() -> None:
with pytest.raises(ValidationError):
McpConnectionConfig.model_validate(
{
"name": "x",
"transport": "stdio",
}
)
def test_empty_name_is_rejected() -> None:
with pytest.raises(ValidationError):
McpConnectionConfig.model_validate(
{
"name": "",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
}
)
def test_unknown_field_is_rejected() -> None:
with pytest.raises(ValidationError):
McpConnectionConfig.model_validate(
{
"name": "x",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
"surprise": True,
}
)
# --- auth headers ------------------------------------------------------------
def test_bearer_auth_builds_authorization_header() -> None:
headers = _auth_headers(_config("files_main", []))
assert headers == {"Authorization": "Bearer run-token"}
# --- namespacing and filtering -----------------------------------------------
def _registered_names() -> list[str]:
return [tool.name for tool in factory.registered_agent_tools()]
@pytest.mark.asyncio
async def test_tools_are_namespaced_per_connection() -> None:
server_a = FakeMCPServer("conn_a", [_mcp_tool("describe")])
server_b = FakeMCPServer("conn_b", [_mcp_tool("describe")])
await _register_server_tools(_config("conn_a", ["describe"]), server_a)
await _register_server_tools(_config("conn_b", ["describe"]), server_b)
# Same remote tool name on two connections does not collide.
assert _registered_names() == ["conn_a.describe", "conn_b.describe"]
@pytest.mark.asyncio
async def test_disallowed_tool_is_not_registered() -> None:
server = FakeMCPServer(
"files_main",
[_mcp_tool("list_files"), _mcp_tool("search")],
)
await _register_server_tools(_config("files_main", ["list_files"]), server)
names = _registered_names()
assert "files_main.list_files" in names
assert "files_main.search" not in names
@pytest.mark.asyncio
async def test_allowed_tools_none_registers_every_listed_tool() -> None:
server = FakeMCPServer(
"local_fs",
[_mcp_tool("read_file"), _mcp_tool("write_file")],
)
config = McpConnectionConfig(name="local_fs", url="https://mcp.example.com", allowed_tools=None)
await _register_server_tools(config, server)
names = _registered_names()
assert "local_fs.read_file" in names
assert "local_fs.write_file" in names
@pytest.mark.asyncio
async def test_allowed_tools_list_restricts_registration() -> None:
server = FakeMCPServer(
"local_fs",
[_mcp_tool("read_file"), _mcp_tool("write_file")],
)
await _register_server_tools(_config("local_fs", ["read_file"]), server)
names = _registered_names()
assert names == ["local_fs.read_file"]
@pytest.mark.asyncio
async def test_registered_tool_routes_to_its_server_with_the_original_name() -> None:
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server
)
tool = tools[0]
output = await tool.on_invoke_tool(None, "{}") # type: ignore[union-attr]
# The call reaches the right server, addressed by the unprefixed remote name.
assert server.calls == [("list_files", {})]
assert output == {"type": "text", "text": "routed:list_files"}
# --- result transform --------------------------------------------------------
@pytest.mark.asyncio
async def test_result_transform_receives_namespaced_name_and_structured_result() -> None:
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
seen: list[tuple[str, Any]] = []
def transform(name: str, structured: Any) -> Any:
seen.append((name, structured))
return {"kept": structured["content"][0]["text"]}
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server, result_transform=transform
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# The underlying MCP call still routes by the unprefixed remote name.
assert server.calls == [("list_files", {})]
# The transform is called with the namespaced name and the parsed result.
assert len(seen) == 1
name, structured = seen[0]
assert name == "files_main.list_files"
# A parsed CallToolResult (dict/list), not a pre-serialized string.
assert structured["content"][0]["text"] == "routed:list_files"
assert structured["isError"] is False
# The transform's return value is exactly what the tool yields.
assert output == {"kept": "routed:list_files"}
@pytest.mark.asyncio
async def test_result_transform_can_rewrite_the_tool_output() -> None:
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
def transform(_name: str, structured: Any) -> Any:
# Keep only a truncated view of the text field.
return structured["content"][0]["text"][:6]
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server, result_transform=transform
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
assert output == "routed"
@pytest.mark.asyncio
async def test_without_result_transform_output_is_unchanged() -> None:
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server, result_transform=None
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# Same shape the SDK produces today: no transform in the path.
assert server.calls == [("list_files", {})]
assert output == {"type": "text", "text": "routed:list_files"}
# --- error status capture ----------------------------------------------------
class ErroringMCPServer(FakeMCPServer):
"""A connected server whose calls come back as MCP errors (isError=True)."""
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
self.calls.append((tool_name, arguments))
return CallToolResult(
content=[TextContent(type="text", text=f"boom:{tool_name}")],
isError=True,
)
@pytest.mark.asyncio
async def test_errored_mcp_result_is_flagged_failed_for_the_tui() -> None:
server = ErroringMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# The error text stays exactly what the agent gets today; a success:False tag
# rides alongside it purely so the TUI can tell the call apart from a success.
assert output == {"type": "text", "text": "boom:list_files", "success": False}
assert _tool_status_from_result(output) == "failed"
@pytest.mark.asyncio
async def test_successful_mcp_result_stays_completed_for_the_tui() -> None:
server = FakeMCPServer("files_main", [_mcp_tool("list_files")])
tools: list[Tool] = await _register_server_tools(
_config("files_main", ["list_files"]), server
)
output = await tools[0].on_invoke_tool(None, "{}") # type: ignore[union-attr]
# A non-error result is untouched and keeps rendering as done.
assert output == {"type": "text", "text": "routed:list_files"}
assert _tool_status_from_result(output) == "completed"
# --- server build branch -----------------------------------------------------
def test_build_server_stdio_branch() -> None:
config = McpConnectionConfig(
name="local_fs",
transport="stdio",
command="my-server",
args=["--flag", "value"],
env={"TOKEN": "x"},
)
server = _build_server(config)
# Built, not connected: no subprocess is launched here.
assert isinstance(server, MCPServerStdio)
assert server.name == "local_fs"
assert server.params.command == "my-server"
assert server.params.args == ["--flag", "value"]
assert server.params.env == {"TOKEN": "x"}
def test_build_server_http_branch() -> None:
server = _build_server(_config("files_main", ["list_files"]))
assert isinstance(server, MCPServerStreamableHttp)
assert server.name == "files_main"
# --- loader ------------------------------------------------------------------
def test_loader_parses_stdio_and_http_entries(tmp_path: Path) -> None:
config_file = tmp_path / "mcp-servers.json"
config_file.write_text(
json.dumps(
[
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
"args": ["-y", "server-filesystem"],
},
{
"name": "files_main",
"transport": "http",
"url": "https://mcp.example.com",
"auth": {"kind": "bearer", "token": "abc"},
"allowed_tools": ["list_files"],
},
]
),
encoding="utf-8",
)
configs = load_user_mcp_configs(config_file)
assert [c.name for c in configs] == ["local_fs", "files_main"]
assert configs[0].transport == "stdio"
assert configs[1].allowed_tools == ["list_files"]
def test_loader_skips_bad_entry_but_keeps_good_ones(tmp_path: Path) -> None:
config_file = tmp_path / "mcp-servers.json"
config_file.write_text(
json.dumps(
[
{"name": "broken", "transport": "http"}, # missing url
{
"name": "local_fs",
"transport": "stdio",
"command": "npx",
},
]
),
encoding="utf-8",
)
configs = load_user_mcp_configs(config_file)
assert [c.name for c in configs] == ["local_fs"]
def test_loader_returns_empty_when_file_absent(tmp_path: Path) -> None:
assert load_user_mcp_configs(tmp_path / "does-not-exist.json") == []
def test_loader_reads_env_var_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config_file = tmp_path / "from-env.json"
config_file.write_text(
json.dumps([{"name": "local_fs", "transport": "stdio", "command": "npx"}]),
encoding="utf-8",
)
monkeypatch.setenv("STRIX_MCP_CONFIG", str(config_file))
configs = load_user_mcp_configs()
assert [c.name for c in configs] == ["local_fs"]
# --- connection notes --------------------------------------------------------
@pytest.mark.asyncio
async def test_connection_notes_are_carried_on_the_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
server = FakeMCPServer("db", [_mcp_tool("query")])
monkeypatch.setattr(mcp_client, "_build_server", lambda _config: server)
config = McpConnectionConfig(
name="db",
url="https://mcp.example.com",
notes="Staging analytics DB; read-only.",
allowed_tools=["query"],
)
connections = await mcp_client.connect_mcp_servers([config])
# Notes ride on the connection (surfaced once), not stapled onto each tool.
assert connections[0].notes == "Staging analytics DB; read-only."
def test_connection_notes_block_lists_only_noted_connections() -> None:
connections = [
ConnectedMcpServer(
server=FakeMCPServer("db", []), name="db", tool_count=2, notes="staging, read-only"
),
ConnectedMcpServer(server=FakeMCPServer("fs", []), name="fs", tool_count=1, notes=None),
]
block = _mcp_connection_notes(connections)
assert block is not None
assert "db" in block
assert "staging, read-only" in block
# A connection without notes is not listed.
assert "fs" not in block
def test_connection_notes_block_is_none_without_notes() -> None:
connections = [
ConnectedMcpServer(server=FakeMCPServer("db", []), name="db", tool_count=1, notes=None)
]
assert _mcp_connection_notes(connections) is None
# --- cancellation cleanup ----------------------------------------------------
@pytest.mark.asyncio
async def test_connect_cleans_up_when_cancelled_mid_connect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cleaned: list[str] = []
class _Tracking(FakeMCPServer):
def __init__(self, name: str, *, fail_connect: bool = False) -> None:
super().__init__(name, [_mcp_tool("t")])
self._fail_connect = fail_connect
async def connect(self) -> None:
if self._fail_connect:
raise asyncio.CancelledError
async def cleanup(self) -> None:
cleaned.append(self._name)
servers = {"good": _Tracking("good"), "bad": _Tracking("bad", fail_connect=True)}
monkeypatch.setattr(mcp_client, "_build_server", lambda config: servers[config.name])
configs = [
McpConnectionConfig(name="good", url="https://mcp.example.com", allowed_tools=["t"]),
McpConnectionConfig(name="bad", url="https://mcp.example.com", allowed_tools=["t"]),
]
with pytest.raises(asyncio.CancelledError):
await mcp_client.connect_mcp_servers(configs)
# The server being connected when cancelled, and the one already connected,
# are both cleaned up rather than orphaned.
assert cleaned == ["bad", "good"]
# --- duplicate names and run selection ---------------------------------------
def _names_file(tmp_path: Path, *names: str) -> Path:
config_file = tmp_path / "mcp-servers.json"
config_file.write_text(
json.dumps([{"name": n, "transport": "stdio", "command": "npx"} for n in names]),
encoding="utf-8",
)
return config_file
def test_loader_drops_duplicate_named_connections(tmp_path: Path) -> None:
config_file = tmp_path / "mcp-servers.json"
config_file.write_text(
json.dumps(
[
{"name": "dup", "transport": "stdio", "command": "first"},
{"name": "dup", "transport": "stdio", "command": "second"},
{"name": "other", "transport": "stdio", "command": "npx"},
]
),
encoding="utf-8",
)
configs = load_user_mcp_configs(config_file)
# Duplicate name is dropped; the first entry wins.
assert [c.name for c in configs] == ["dup", "other"]
assert configs[0].command == "first"
def test_loader_include_selection_keeps_only_named(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_file = _names_file(tmp_path, "a", "b", "c")
monkeypatch.setenv("STRIX_MCP_ONLY", "a,c")
configs = load_user_mcp_configs(config_file)
assert [c.name for c in configs] == ["a", "c"]
def test_loader_exclude_selection_drops_named(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_file = _names_file(tmp_path, "a", "b", "c")
monkeypatch.setenv("STRIX_MCP_EXCLUDE", "b")
configs = load_user_mcp_configs(config_file)
assert [c.name for c in configs] == ["a", "c"]
+7 -49
View File
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from strix.core.paths import latest_run_dir, runs_base_dir
from strix.interface.viewer.cli import run_view
from strix.interface.viewer.server import serve
from strix.interface.viewer.transcript import (
build_run_state,
@@ -49,31 +48,6 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
assert runs_base_dir() == tmp_path / "strix_runs"
def test_view_cli_help_includes_host(capsys: pytest.CaptureFixture[str]) -> None:
try:
run_view(["--help"])
except SystemExit as exc:
assert exc.code == 0
else:
raise AssertionError("--help should exit")
help_text = capsys.readouterr().out
assert "--host HOST" in help_text
assert "0.0.0.0" in help_text
def test_server_can_bind_all_ipv4_interfaces(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "remote", status="running", end_time=None)
httpd, url, _ = serve(run_dir, host="0.0.0.0", open_browser=False)
try:
assert httpd.server_address[0] == "0.0.0.0"
assert url == f"http://0.0.0.0:{httpd.server_address[1]}"
finally:
httpd.shutdown()
httpd.server_close()
def test_latest_run_dir_picks_newest_by_record_mtime(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -199,15 +173,14 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
httpd, url, token = serve(run_dir, open_browser=False)
httpd, url, _ = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
status, ctype, body = _get(f"{url}/api/run")
assert status == 200
assert "application/json" in ctype
assert json.loads(body)["finished"] is True
status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
status, _, body = _get(f"{url}/api/transcript")
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
# Real asset is served.
@@ -456,22 +429,6 @@ def test_unauthorized_client_cannot_acquire_capability(
httpd.server_close()
def test_run_data_requires_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "private", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
httpd, url, token = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
for path in ("/api/run", "/api/vulnerabilities", "/api/report", "/api/transcript"):
assert _get_status(url + path) == 403, path
assert _get_status(url + path, cookie=f"{_cookie_name(url)}=wrong") == 403, path
assert _get_status(url + path, cookie=cookie) == 200, path
finally:
httpd.shutdown()
httpd.server_close()
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
@@ -604,10 +561,11 @@ def test_historical_run_data_requires_verification(
httpd, url, token = serve(launched, open_browser=False)
try:
# The launched run needs the session capability, but not email verification.
assert _get_status(f"{url}/api/run") == 403
# The launched run is always viewable, no verification and no cookie.
status, _, _ = _get(f"{url}/api/run")
assert status == 200
cookie = _session_cookie(url, token)
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
# A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified.