mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 18:52:47 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afda373f55 | ||
|
|
305cb13998 | ||
|
|
b30ed45ed1 | ||
|
|
8fb83f52b1 | ||
|
|
209584e7fd | ||
|
|
6f88b7d7d5 | ||
|
|
8d3693df8c | ||
|
|
9cd81e5c76 | ||
|
|
e8272c6a21 |
@@ -167,10 +167,15 @@ strix view
|
|||||||
|
|
||||||
# ...or open a specific run by name
|
# ...or open a specific run by name
|
||||||
strix view my-run-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.
|
`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
|
### What's in the dashboard
|
||||||
|
|
||||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||||
@@ -315,6 +320,30 @@ strix auth status # show the active sign-in
|
|||||||
strix auth logout # forget the 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:**
|
**Recommended models for best results:**
|
||||||
|
|
||||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||||
|
|||||||
+2
-1
@@ -47,7 +47,8 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"integrations/github-actions",
|
"integrations/github-actions",
|
||||||
"integrations/ci-cd",
|
"integrations/ci-cd",
|
||||||
"integrations/coding-agents"
|
"integrations/coding-agents",
|
||||||
|
"integrations/mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -241,6 +241,8 @@ ignore = [
|
|||||||
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
||||||
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
||||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
"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
|
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ from strix.tools.output_store import (
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from agents.mcp import MCPServer
|
||||||
from agents.memory import SQLiteSession
|
from agents.memory import SQLiteSession
|
||||||
from agents.result import RunResultBase
|
from agents.result import RunResultBase
|
||||||
|
|
||||||
from strix.runtime.status import StatusSink
|
from strix.runtime.status import StatusSink
|
||||||
|
from strix.tools.mcp import ConnectedMcpServer
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -62,6 +64,33 @@ logger = logging.getLogger(__name__)
|
|||||||
StreamEventSink = Callable[[str, Any], None]
|
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(
|
def _merge_root_prompt_context(
|
||||||
scope_context: dict[str, Any],
|
scope_context: dict[str, Any],
|
||||||
extra_system_prompt_context: dict[str, Any] | None,
|
extra_system_prompt_context: dict[str, Any] | None,
|
||||||
@@ -253,6 +282,7 @@ async def run_strix_scan(
|
|||||||
configure_spill_writer(_spill_to_workspace)
|
configure_spill_writer(_spill_to_workspace)
|
||||||
|
|
||||||
sessions_to_close: list[SQLiteSession] = []
|
sessions_to_close: list[SQLiteSession] = []
|
||||||
|
mcp_servers: list[MCPServer] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
targets = scan_config.get("targets") or []
|
targets = scan_config.get("targets") or []
|
||||||
@@ -298,6 +328,24 @@ async def run_strix_scan(
|
|||||||
system_prompt_context=root_context,
|
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(
|
root_agent = build_strix_agent(
|
||||||
name="Root Agent",
|
name="Root Agent",
|
||||||
skills=skills,
|
skills=skills,
|
||||||
@@ -472,6 +520,9 @@ async def run_strix_scan(
|
|||||||
for s in sessions_to_close:
|
for s in sessions_to_close:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
s.close()
|
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):
|
with contextlib.suppress(Exception):
|
||||||
await coordinator._maybe_snapshot()
|
await coordinator._maybe_snapshot()
|
||||||
if cleanup_on_exit:
|
if cleanup_on_exit:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -219,6 +220,30 @@ Examples:
|
|||||||
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
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(
|
parser.add_argument(
|
||||||
"--max-budget",
|
"--max-budget",
|
||||||
"--max-budget-usd",
|
"--max-budget-usd",
|
||||||
@@ -267,6 +292,20 @@ Examples:
|
|||||||
if args.config:
|
if args.config:
|
||||||
apply_config_override(validate_config_file(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:
|
if args.update:
|
||||||
sys.exit(0 if self_update() else 1)
|
sys.exit(0 if self_update() else 1)
|
||||||
|
|
||||||
|
|||||||
@@ -22,19 +22,20 @@ func statusIcon(status string) (string, lipgloss.Style) {
|
|||||||
return "○ Unknown", Dim()
|
return "○ Unknown", Dim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderGenericTool ports registry._render_default_tool_widget.
|
// renderGenericTool ports registry._render_default_tool_widget. It shows the
|
||||||
func renderGenericTool(name string, args map[string]any, result any, status string) string {
|
// 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 {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
|
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
|
||||||
for _, k := range SortedKeys(args) {
|
for _, k := range SortedKeys(args) {
|
||||||
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
|
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
|
||||||
}
|
}
|
||||||
if (status == "completed" || status == "failed" || status == "error") && result != nil {
|
icon, style := statusIcon(status)
|
||||||
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result: ") + StringValue(result))
|
b.WriteString(style.Render(icon))
|
||||||
} else {
|
|
||||||
icon, style := statusIcon(status)
|
|
||||||
b.WriteString(style.Render(icon))
|
|
||||||
}
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ func Tool(data map[string]any) string {
|
|||||||
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
|
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
|
||||||
return renderProxyTool(name, args, result, status)
|
return renderProxyTool(name, args, result, status)
|
||||||
}
|
}
|
||||||
return renderGenericTool(name, args, result, status)
|
return renderGenericTool(name, args, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func TestToolDispatchCoversKnownTools(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"unknown tool falls back to generic",
|
"unknown tool falls back to generic",
|
||||||
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
|
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
|
||||||
[]string{"brand_new_tool", "alpha", "Result:", "done"},
|
[]string{"brand_new_tool", "alpha", "Done"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +214,18 @@ 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) {
|
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
|
||||||
lines := make([]string, 16)
|
lines := make([]string, 16)
|
||||||
for i := range lines {
|
for i := range lines {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ def run_view(argv: list[str]) -> None:
|
|||||||
default=0,
|
default=0,
|
||||||
help="Port to serve on (default: an available ephemeral port).",
|
help="Port to serve on (default: an available ephemeral port).",
|
||||||
)
|
)
|
||||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
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(
|
parser.add_argument(
|
||||||
"--no-open",
|
"--no-open",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ class _ViewerState:
|
|||||||
# exchanged for a session cookie only when presented on the initial page
|
# exchanged for a session cookie only when presented on the initial page
|
||||||
# load. It is the request-level authorization the review asked for:
|
# load. It is the request-level authorization the review asked for:
|
||||||
# reachability of the port (e.g. when bound with ``--host``) is not
|
# reachability of the port (e.g. when bound with ``--host``) is not
|
||||||
# enough to steer a live scan, trigger a report, or browse history --
|
# enough to read run data, steer a live scan, trigger a report, or
|
||||||
# the token is never handed to a caller who merely reaches ``/``.
|
# browse history -- the token is never handed to a caller who merely
|
||||||
|
# reaches ``/``.
|
||||||
self.session_token = secrets.token_urlsafe(32)
|
self.session_token = secrets.token_urlsafe(32)
|
||||||
# Finalized in ``serve()`` once the port is known (the server binds
|
# Finalized in ``serve()`` once the port is known (the server binds
|
||||||
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||||
@@ -234,11 +235,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||||
# The launched run is always viewable with no verification. The
|
# The cross-run history list (/api/runs) unlocks its entries only for
|
||||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
# a caller that holds this process's session capability *and* is
|
||||||
# caller that holds this process's session capability *and* is email
|
# email verified, so merely reaching an exposed --host port never
|
||||||
# verified, so merely reaching an exposed --host port never leaks the
|
# leaks the run list (the payload still advertises the count as a
|
||||||
# run list (the payload still advertises the count as a teaser).
|
# teaser).
|
||||||
if path == "/api/runs":
|
if path == "/api/runs":
|
||||||
unlocked = self._has_session() and auth.is_verified()
|
unlocked = self._has_session() and auth.is_verified()
|
||||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||||
@@ -253,6 +254,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._handle_auth_status()
|
self._handle_auth_status()
|
||||||
return
|
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_values = query.get("run")
|
||||||
run_param = run_values[0] if run_values else None
|
run_param = run_values[0] if run_values else None
|
||||||
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
|
||||||
@@ -260,18 +268,12 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# The launched run is always viewable. Any *other* run's data is part
|
# Any run other than the one used to launch the viewer is part of the
|
||||||
# of the gated history: it needs this process's session capability
|
# email-gated history. The session check above applies to both paths;
|
||||||
# (so merely reaching an exposed --host port is not enough) *and*
|
# verification adds a second gate for historical run data.
|
||||||
# email verification -- otherwise knowing a run name would leak its
|
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||||
# metadata, vulnerabilities, report, and transcript.
|
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||||
if run_dir.resolve() != state.run_dir.resolve():
|
return
|
||||||
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":
|
if path == "/api/run":
|
||||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||||
@@ -385,7 +387,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
except auth.RelayError as exc:
|
except auth.RelayError as exc:
|
||||||
self._send_relay_error(exc)
|
self._send_relay_error(exc)
|
||||||
return
|
return
|
||||||
# The password is returned only to the local (127.0.0.1) browser.
|
# The password is returned only to a session-authorized browser.
|
||||||
self._send_json(
|
self._send_json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
{"ok": True, "password": password, "filename": filename},
|
{"ok": True, "password": password, "filename": filename},
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ Notable source-aware skills:
|
|||||||
- `source_aware_sast` (custom): semgrep/AST/secrets/supply-chain static triage workflow
|
- `source_aware_sast` (custom): semgrep/AST/secrets/supply-chain static triage workflow
|
||||||
- `dependency_cve_scanning` (custom): trivy-based SCA workflow for reporting known dependency CVEs via `create_dependency_report`
|
- `dependency_cve_scanning` (custom): trivy-based SCA workflow for reporting known dependency CVEs via `create_dependency_report`
|
||||||
- `npx_confusion` (custom): npx/npm exec/bunx fallback and adjacent package-runner identity confusion, with runner-specific registry and reporting gates
|
- `npx_confusion` (custom): npx/npm exec/bunx fallback and adjacent package-runner identity confusion, with runner-specific registry and reporting gates
|
||||||
|
- `semantic_confusion` (vulnerabilities): cross-boundary parser, normalization, and representation mismatch analysis
|
||||||
- `agentic_system_security` (vulnerabilities): effective-authority and MCP/tool ecosystem security testing
|
- `agentic_system_security` (vulnerabilities): effective-authority and MCP/tool ecosystem security testing
|
||||||
|
- `browser_security` (vulnerabilities): browsing-context, postMessage, XS-Leaks, service-worker, and cross-origin state-machine testing
|
||||||
- `azure` (cloud): Azure and Microsoft Entra privilege, PIM, workload identity, and cross-plane escalation analysis
|
- `azure` (cloud): Azure and Microsoft Entra privilege, PIM, workload identity, and cross-plane escalation analysis
|
||||||
- `infrastructure_lifecycle` (reconnaissance): abandoned or mutable external dependencies such as update endpoints, MX, storage, and control domains
|
- `infrastructure_lifecycle` (reconnaissance): abandoned or mutable external dependencies such as update endpoints, MX, storage, and control domains
|
||||||
- `argument_injection` (vulnerabilities): shell-free CLI option smuggling, secondary argument-file parsing, and platform-specific argv transformation boundaries
|
- `argument_injection` (vulnerabilities): shell-free CLI option smuggling, secondary argument-file parsing, and platform-specific argv transformation boundaries
|
||||||
|
- `electron_desktop_apps` (technologies): Electron renderer-to-native trust boundaries, preload/IPC exposure, and navigation analysis
|
||||||
|
|
||||||
Notable LLM security skills:
|
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_applications` (technologies): end-to-end OWASP 2026 LLM01-LLM10 coverage across models, RAG, vectors, agents, tools, outputs, supply chain, and resource controls
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ tree-sitter parse -q <file>
|
|||||||
|
|
||||||
Use outputs to improve route/symbol/sink maps for subsequent targeted scans.
|
Use outputs to improve route/symbol/sink maps for subsequent targeted scans.
|
||||||
|
|
||||||
|
## Cross-Component Semantic Mapping
|
||||||
|
|
||||||
|
Pattern scanners find local sinks but often miss a security decision in one component followed by a different interpretation in another. For complex middleware, proxies, frameworks, and plugin systems:
|
||||||
|
|
||||||
|
1. Identify shared request/context fields and every writer/reader.
|
||||||
|
2. Order the readers and writers by lifecycle phase: parse, route, authenticate, rewrite, authorize, dispatch, render.
|
||||||
|
3. Mark fields whose semantic type changes (URL/path, MIME/handler, alias/package, external/internal route).
|
||||||
|
4. Trace normal, error, retry, subrequest, and internal-redirect paths separately.
|
||||||
|
5. Compare the representation checked by security code with the representation consumed by the final sink.
|
||||||
|
|
||||||
|
Load `semantic_confusion` when this graph reveals overloaded fields, multiple parsers, normalization steps, or protocol translation.
|
||||||
|
|
||||||
## Resolution and Namespace Risks
|
## Resolution and Namespace Risks
|
||||||
|
|
||||||
In repositories with developer tooling, plugins, templates, or package runners, inspect lookup order rather than only dependency versions:
|
In repositories with developer tooling, plugins, templates, or package runners, inspect lookup order rather than only dependency versions:
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
name: electron-desktop-apps
|
||||||
|
description: Test Electron desktop applications across renderer, preload, IPC, main-process, navigation, custom-protocol, storage, permission, and update trust boundaries; use for packaged Electron apps, ASAR review, web-to-native capability analysis, and Electron-specific exploit chains
|
||||||
|
---
|
||||||
|
|
||||||
|
# Electron Desktop Applications
|
||||||
|
|
||||||
|
Use this skill for Electron applications. Other webview desktop frameworks may share the high-level web-to-native trust question, but their bridge, sandbox, update, and process APIs differ; do not apply Electron-specific conclusions to NW.js, CEF, Tauri, or Wails without mapping that framework separately.
|
||||||
|
|
||||||
|
Pair this skill with `browser_security` for browser state and navigation, `xss` for renderer injection, `argument_injection` for native subprocess launches, and `insecure_deserialization` or `rce` for a main-process sink.
|
||||||
|
|
||||||
|
## Architecture and Authority Map
|
||||||
|
|
||||||
|
Inventory each security principal and the capabilities crossing between them:
|
||||||
|
|
||||||
|
```text
|
||||||
|
origin + document + frame
|
||||||
|
-> renderer JavaScript
|
||||||
|
-> preload isolated world
|
||||||
|
-> contextBridge API
|
||||||
|
-> IPC channel
|
||||||
|
-> sender/argument/identity checks
|
||||||
|
-> main process or utility process
|
||||||
|
-> filesystem, process, credential, media, network, update, or OS action
|
||||||
|
```
|
||||||
|
|
||||||
|
Record:
|
||||||
|
|
||||||
|
- Electron, Chromium, Node, and application versions
|
||||||
|
- packaging form, `app.asar`, unpacked resources, entry point, and fuses
|
||||||
|
- every `BrowserWindow`, `WebContentsView`, `<webview>`, session/partition, and child window
|
||||||
|
- `webPreferences`: `preload`, `nodeIntegration`, `contextIsolation`, `sandbox`, `webSecurity`, `allowRunningInsecureContent`, experimental features, and subframe/worker integration
|
||||||
|
- every preload export and every `ipcMain.handle`/`ipcMain.on` consumer
|
||||||
|
- origins/documents/frames that can reach each exported API
|
||||||
|
- custom protocols, deep links, navigation helpers, permissions, downloads, storage, and update channels
|
||||||
|
|
||||||
|
Do not infer authority from a setting or channel name alone. Follow one request from renderer input to the main-process side effect and record each authorization decision.
|
||||||
|
|
||||||
|
## Package and Source Reconnaissance
|
||||||
|
|
||||||
|
Extract the application bundle with a reviewed, version-pinned ASAR implementation or inspect an already unpacked `resources/app` tree. Locate `package.json#main`, preload paths, build metadata, Electron version, native modules, and update configuration.
|
||||||
|
|
||||||
|
Search for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BrowserWindow WebContentsView webviewTag webPreferences
|
||||||
|
preload contextBridge.exposeInMainWorld ipcRenderer
|
||||||
|
ipcMain.handle ipcMain.on webContents.ipc
|
||||||
|
will-navigate will-frame-navigate will-redirect
|
||||||
|
setWindowOpenHandler loadURL loadFile openExternal
|
||||||
|
setPermissionRequestHandler registerSchemesAsPrivileged
|
||||||
|
setAsDefaultProtocolClient open-url second-instance
|
||||||
|
autoUpdater electron-updater
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat decompiled or bundled JavaScript as a hypothesis when source maps, minification, generated IPC bindings, or runtime feature flags can change the installed behavior.
|
||||||
|
|
||||||
|
## Preload and Context-Bridge Analysis
|
||||||
|
|
||||||
|
A preload script has privileged Electron/Node access even when `nodeIntegration` is disabled. With context isolation, it can still expose selected functions and values into the page's main world.
|
||||||
|
|
||||||
|
Classify every export:
|
||||||
|
|
||||||
|
- narrow operation with fixed channel and validated arguments
|
||||||
|
- caller-selected channel or event name
|
||||||
|
- direct exposure of `ipcRenderer`, Node/Electron modules, filesystem/process objects, or mutable privileged objects
|
||||||
|
- callback/event registration that leaks the raw IPC event or privileged objects
|
||||||
|
- secret/session/storage access
|
||||||
|
- operation whose authorization exists only in renderer JavaScript
|
||||||
|
|
||||||
|
A generic `send(channel, ...)` or `invoke(channel, ...)` bridge expands the renderer's candidate capability set, but the registered handler list is not the ACL. For each handler, inspect:
|
||||||
|
|
||||||
|
- `event.senderFrame` URL/origin and frame identity validation
|
||||||
|
- expected `webContents`, window, session/partition, and application state
|
||||||
|
- user/tenant authorization and request provenance
|
||||||
|
- argument schema, paths, URLs, command options, and object deserialization
|
||||||
|
- result exposure and event subscriptions
|
||||||
|
|
||||||
|
An IPC handler's existence does not prove an untrusted frame can invoke it successfully.
|
||||||
|
|
||||||
|
## Navigation and Window Boundaries
|
||||||
|
|
||||||
|
Web preferences belong to a `webContents`; navigation does not automatically turn a privileged window into an ordinary browser tab. A configured preload can run for newly loaded documents and expose its bridge to content that was never intended to receive it.
|
||||||
|
|
||||||
|
Map all navigation causes:
|
||||||
|
|
||||||
|
- user- or page-initiated main-frame navigation (`will-navigate`)
|
||||||
|
- subframe navigation (`will-frame-navigate`)
|
||||||
|
- server redirects (`will-redirect`)
|
||||||
|
- new windows and popups (`setWindowOpenHandler`)
|
||||||
|
- application calls to `loadURL`, `loadFile`, history APIs, or routing helpers
|
||||||
|
- custom-protocol redirects and external-link handlers
|
||||||
|
|
||||||
|
`will-navigate` does not cover every programmatic navigation, so the event's presence is not complete enforcement.
|
||||||
|
|
||||||
|
Parse candidate URLs with `URL` and compare explicit protocol, origin/host, port, and path rules. Do not use string-prefix checks such as `startsWith("https://trusted.example")`. Apply the same canonical policy to initial loads, redirects, frames, popups, programmatic loads, and externally opened URLs.
|
||||||
|
|
||||||
|
Before calling `shell.openExternal`, validate the scheme and complete destination expected by the feature. Treat `file:`, custom schemes, handler-specific arguments, credentials in URLs, and ambiguous encodings as separate cases.
|
||||||
|
|
||||||
|
## Node, Isolation, and Sandbox Settings
|
||||||
|
|
||||||
|
- `nodeIntegration: true` in a renderer that can execute untrusted script directly exposes Node capability and commonly turns renderer injection into native code execution.
|
||||||
|
- `contextIsolation: false` weakens the boundary between page and preload worlds but is not, by itself, proof of native code execution.
|
||||||
|
- `sandbox: false` removes Chromium process isolation; determine which preload or renderer capabilities become reachable rather than reporting the flag alone.
|
||||||
|
- `webSecurity: false`, `allowRunningInsecureContent`, permissive experimental features, and unsafe `<webview>` preferences change separate browser boundaries and must be traced to an exploit path.
|
||||||
|
- `nodeIntegrationInSubFrames` and preload injection into frames require frame-by-frame sender and origin analysis.
|
||||||
|
|
||||||
|
Record Electron-version defaults. A missing explicit setting can mean different behavior on different major releases.
|
||||||
|
|
||||||
|
## Custom Protocols and Deep Links
|
||||||
|
|
||||||
|
Treat OS-delivered URLs and second-instance command lines as attacker-controlled inputs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OS handler / browser / document
|
||||||
|
-> custom scheme or argv
|
||||||
|
-> URL/argument parsing
|
||||||
|
-> application router
|
||||||
|
-> renderer navigation or native operation
|
||||||
|
```
|
||||||
|
|
||||||
|
Test authority and parser boundaries for host/path normalization, duplicate parameters, encoding depth, file paths, option injection, and cross-profile/account routing. Confirm which application instance and user session receives the event.
|
||||||
|
|
||||||
|
For custom application protocols, record whether the scheme is registered as secure, standard, CORS-enabled, stream-capable, or privileged, and how that affects origin and storage behavior.
|
||||||
|
|
||||||
|
## Permissions, Storage, and Secrets
|
||||||
|
|
||||||
|
Map session permission handlers for media, notifications, geolocation, clipboard, display capture, USB/HID/serial, filesystem access, and external protocols. Verify decisions use the requesting frame/origin and cannot be inherited from a more trusted window.
|
||||||
|
|
||||||
|
Inventory secrets and capability-bearing state reachable from renderer or preload code:
|
||||||
|
|
||||||
|
- tokens, cookies, session identifiers, recovery material, and encryption keys
|
||||||
|
- IndexedDB, local/session storage, cookies, cache, filesystem databases, and keychain wrappers
|
||||||
|
- local service ports, named pipes, Unix sockets, and authentication material
|
||||||
|
|
||||||
|
At-rest encryption does not protect data when the renderer can retrieve the key or ask a privileged bridge to decrypt it.
|
||||||
|
|
||||||
|
## Updates and Native Extensions
|
||||||
|
|
||||||
|
Trace the update pipeline as an executable supply chain:
|
||||||
|
|
||||||
|
- feed URL and channel selection
|
||||||
|
- TLS identity, redirects, proxy behavior, and metadata parsing
|
||||||
|
- artifact signature and publisher verification
|
||||||
|
- version/rollback policy and staged update state
|
||||||
|
- native modules, helper binaries, installers, and post-update hooks
|
||||||
|
|
||||||
|
An attacker-controlled feed is not automatically native code execution if independent artifact signatures are mandatory. Conversely, HTTPS does not compensate for missing artifact authenticity or unsafe rollback behavior.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Record the exact installed build, Electron version, preferences, preload, handler, and current document/frame origin.
|
||||||
|
- Demonstrate the complete path from attacker-controlled input or renderer state to the main-process operation.
|
||||||
|
- Capture sender-validation and argument-validation outcomes, not only successful IPC transport.
|
||||||
|
- Re-test after cross-origin navigation, redirect, frame creation, window creation, and session/profile changes.
|
||||||
|
- Separate renderer script execution, bridge access, accepted IPC, privileged data access, filesystem/process control, and native code execution.
|
||||||
|
|
||||||
|
## False Positives
|
||||||
|
|
||||||
|
- A preload or handler exists but the tested document/frame cannot reach it.
|
||||||
|
- A channel is registered but rejects the sender, identity, state, or arguments.
|
||||||
|
- `contextIsolation` or sandboxing is disabled without a reachable privileged API.
|
||||||
|
- Navigation is blocked on user links but still possible through application code, or vice versa.
|
||||||
|
- A remote page has no preload export, Node integration, IPC route, or privileged permission.
|
||||||
|
- An update feed is mutable but every artifact and version transition is independently authenticated.
|
||||||
|
- A secret-looking value is scoped to synthetic/test data or cannot authorize any downstream action.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
- Load local application UI and isolate remote content in an unprivileged `WebContentsView` or external browser.
|
||||||
|
- Keep Node integration disabled, context isolation enabled, and renderer sandboxing enabled.
|
||||||
|
- Expose narrow preload APIs with fixed operations and strict schemas.
|
||||||
|
- Validate every IPC sender frame, application identity, authorization context, and argument in the main process.
|
||||||
|
- Parse and allowlist navigation destinations consistently across every navigation path.
|
||||||
|
- Restrict permissions per session and requesting origin.
|
||||||
|
- Keep credentials and encryption keys outside renderer reach.
|
||||||
|
- Authenticate update metadata and artifacts, enforce rollback policy, and pin publishers.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Electron security depends on which document and frame can reach which native capability. Map navigation, preload exports, IPC sender checks, permissions, storage, protocols, and updates as one authority graph, then validate the entire path to the privileged operation.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
name: browser-security
|
||||||
|
description: Browser-internals security testing for browsing-context relationships, postMessage, client-side path traversal, XS-Leaks, service workers, Web Workers, navigation behavior, CSP interactions, caches, and cross-origin state machines
|
||||||
|
---
|
||||||
|
|
||||||
|
# Browser Security
|
||||||
|
|
||||||
|
Use this skill when exploitability depends on browser behavior beyond a basic HTML injection. Model origins, browsing contexts, navigation history, workers, caches, router decoding, request metadata, and user activation as explicit state.
|
||||||
|
|
||||||
|
Pair this skill with `xss`, `oauth`, `open_redirect`, `csrf`, or `semantic_confusion` when one of those is the primary vulnerability class. For an Electron renderer with a preload or IPC bridge, load `electron_desktop_apps` to analyze whether navigation and origin transitions reach native capability.
|
||||||
|
|
||||||
|
## Safety Boundary
|
||||||
|
|
||||||
|
- Use a controlled browser profile, synthetic account/data, explicit target allowlist, and a fresh assessment-specific proxy/CA when interception is required.
|
||||||
|
- Redact tokens, cookies, message contents, storage values, and personal data from console logs, captures, recordings, and reports.
|
||||||
|
- Treat oversized URLs/headers, cookie inflation, redirect loops, cache exhaustion, and high-rate timing trials as resource/denial-of-service tests; run them only with strict ceilings in a restartable lab.
|
||||||
|
- Do not attempt to set or spoof browser-generated `event.origin`. Vary the sender URL and record the serialized origin supplied by the browser.
|
||||||
|
- Restore monkey-patched browser APIs and unregister test workers/caches after validation.
|
||||||
|
|
||||||
|
## Browser State Model
|
||||||
|
|
||||||
|
For each relevant page or worker, record:
|
||||||
|
|
||||||
|
- origin and site, including transitions after navigation
|
||||||
|
- top-level window, opener, parent, child frames, named contexts, and retained references
|
||||||
|
- sandbox flags, CSP `frame-ancestors`, COOP, COEP, CORP, and X-Frame-Options
|
||||||
|
- service-worker controller and scope
|
||||||
|
- storage access: cookies, local/session storage, IndexedDB, Cache API
|
||||||
|
- navigation/history entries and redirect type: HTTP, JavaScript, form, meta refresh
|
||||||
|
- user-activation and interaction requirements
|
||||||
|
- browser family/version and enabled experimental features
|
||||||
|
|
||||||
|
Draw the context graph. Security checks on `event.origin`, `event.source`, or a popup reference are meaningful only when the lifetime and ownership of that context are understood.
|
||||||
|
|
||||||
|
## High-Value Surfaces
|
||||||
|
|
||||||
|
### postMessage and Window Relationships
|
||||||
|
|
||||||
|
- Enumerate listeners and senders; record message schema, origin check, source check, and reachable sinks/actions.
|
||||||
|
- Validate origins after URL parsing and canonicalization, not with raw-string regexes.
|
||||||
|
- Test numeric/alternate IP forms, userinfo, path masquerading as a host suffix, and redirects.
|
||||||
|
- Treat predictable `window.open()` target names and iframe names as potentially shared namespace entries. Confirm reuse within the same browsing-context group, opener chain, COOP state, and relevant navigation/message timing.
|
||||||
|
- Check whether a blocked intermediate frame leaves a useful browsing-context relationship intact.
|
||||||
|
- Use random per-flow names or `_blank` with `noopener` where an opener relationship is unnecessary.
|
||||||
|
|
||||||
|
### Client-Side Path Traversal
|
||||||
|
|
||||||
|
Trace the complete source-to-request pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser URL -> router parser -> route/query/hash accessor -> app interpolation -> fetch/XHR -> final normalized URL
|
||||||
|
```
|
||||||
|
|
||||||
|
- Test path parameters, query parameters, and hashes independently.
|
||||||
|
- Determine exactly where `%2F`, `%5C`, `%2E`, and double-encoded forms decode or re-encode.
|
||||||
|
- Instrument `fetch`, XHR, Axios, router navigation, and server-side fetch wrappers to capture the final URL.
|
||||||
|
- Escalate only after identifying the sink: state-changing API for CSRF-like impact, HTML/attachment response rendered in an unsafe sink for XSS, or server-side fetch for SSRF.
|
||||||
|
- Do not assume the same framework API behaves identically in client components, server components, and route handlers.
|
||||||
|
|
||||||
|
### XS-Leaks and Cross-Origin Oracles
|
||||||
|
|
||||||
|
Inventory observable signals that do not require reading the cross-origin response:
|
||||||
|
|
||||||
|
- load/error events for script, image, stylesheet, frame, media, and module elements
|
||||||
|
- timing, connection reuse, cache state, redirect count, and navigation success
|
||||||
|
- window/frame count, focus, history length, and resource dimensions
|
||||||
|
- browser-generated error pages and status-dependent behavior
|
||||||
|
- request headers such as `Sec-Fetch-Dest`, `Sec-Fetch-Mode`, and `Origin`
|
||||||
|
|
||||||
|
Test controls such as ORB, CORP, COEP, and MIME enforcement. A service worker or alternate fetch path can change request destination metadata and therefore change whether a blocked response becomes a network error or an empty response. Validate the oracle across authenticated and unauthenticated control cases.
|
||||||
|
|
||||||
|
### Service Workers and Caches
|
||||||
|
|
||||||
|
- Map service-worker registration scope, update lifecycle, controller acquisition, and fetch handlers.
|
||||||
|
- Inspect Cache API keys and responses; determine whether HTML or JavaScript is served directly from a writable cache.
|
||||||
|
- Test whether a constrained script context can poison app-managed cache entries later consumed by a normal page or service worker.
|
||||||
|
- Treat service-worker persistence as high impact, but prove registration/control scope and update survivability.
|
||||||
|
- Compare a direct subresource request with the same request proxied through `fetch(event.request)`; request destination and mode can differ.
|
||||||
|
|
||||||
|
### Web Workers and Constrained Script Execution
|
||||||
|
|
||||||
|
When script runs inside a worker, inventory capabilities instead of dismissing it as low impact:
|
||||||
|
|
||||||
|
- credentialed same-origin `fetch` for data access and state changes
|
||||||
|
- `postMessage` gadgets into the main page
|
||||||
|
- IndexedDB and Cache API shared with other same-origin contexts
|
||||||
|
- Blob construction and object URLs
|
||||||
|
- import mechanisms, WebSocket, and available browser-specific APIs
|
||||||
|
|
||||||
|
Prove the strongest reliable capability first. If escalation requires a user gesture, document the exact gesture, timing, browser, and visibility rather than calling it zero-click XSS.
|
||||||
|
|
||||||
|
### Navigation and Redirect Control
|
||||||
|
|
||||||
|
- Distinguish HTTP 30x, script navigation, form submission, meta refresh, and popup navigation.
|
||||||
|
- Test invalid or blocked URL schemes and WAF-generated error pages only when they support a real flow. Oversized URLs/headers, cookie-path-specific header inflation, redirect limits, and navigation throttling are restartable-lab-only tests with strict size/iteration limits and health checks.
|
||||||
|
- A sandbox inherited by a new top-level context can selectively block forms, scripts, popups, or navigation; enumerate the exact flag set.
|
||||||
|
- Preserve and inspect history when a built-in error page replaces the active document; do not assume the errored URL is lost.
|
||||||
|
|
||||||
|
### CSP and Browser Parsing
|
||||||
|
|
||||||
|
- Evaluate the delivered policy on the exact response, including redirects and error/API/static paths.
|
||||||
|
- Map nonces, hashes, `strict-dynamic`, allowed schemes, trusted script gadgets, `base-uri`, `frame-ancestors`, and Trusted Types.
|
||||||
|
- Test parser namespaces and repairs in HTML, SVG, and MathML. A protected attribute or sanitizer rule in the HTML namespace may behave differently after namespace transitions.
|
||||||
|
- Treat scriptless disclosure of a nonce or trusted URL as a primitive; prove a second controllable sink before claiming bypass.
|
||||||
|
- For response splitting, consider whether a same-origin endpoint can be turned into a script resource with a controlled body length or framing.
|
||||||
|
|
||||||
|
### JavaScript Gadget Discovery
|
||||||
|
|
||||||
|
- When direct calls are blocked, inspect implicit coercions (`toString`, `valueOf`, iterators, getters, proxies) and callbacks invoked by accessible library functions.
|
||||||
|
- Search for functions whose `this` object and arguments can be attacker-shaped.
|
||||||
|
- Build a bounded harness to enumerate reachable globals and observe property reads/calls; avoid assuming one library gadget is universal.
|
||||||
|
- Validate the complete call chain to a dangerous sink such as navigation, HTML insertion, `eval`, `Function`, or a privileged API.
|
||||||
|
|
||||||
|
## Reconnaissance
|
||||||
|
|
||||||
|
### Runtime Instrumentation
|
||||||
|
|
||||||
|
Instrument in a controlled browser session:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const realFetch = window.fetch;
|
||||||
|
window.fetch = (...args) => {
|
||||||
|
const input = args[0];
|
||||||
|
const rawUrl = typeof input === 'string' ? input : input.url;
|
||||||
|
const url = new URL(rawUrl, location.href);
|
||||||
|
const method = args[1]?.method || input?.method || 'GET';
|
||||||
|
console.log('fetch', {method, origin: url.origin, path: url.pathname});
|
||||||
|
return realFetch(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', e => {
|
||||||
|
const keys = e.data && typeof e.data === 'object' ? Object.keys(e.data) : [];
|
||||||
|
console.log('message', {origin: e.origin, sourceMatches: e.source === window.opener, keys});
|
||||||
|
}, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the wrapper only in the controlled profile and restore `window.fetch = realFetch` afterward. Do not log bodies, message values, credentials, or query strings.
|
||||||
|
|
||||||
|
Also inspect DevTools network initiators, service workers, storage, CSP violations, frame tree, and navigation history. Use raw browser behavior for validation; command-line HTTP clients cannot reproduce origin/window/worker semantics.
|
||||||
|
|
||||||
|
### Source Review
|
||||||
|
|
||||||
|
- Search for `postMessage`, message listeners, `window.open`, named targets, opener/parent access, frame creation, and sandbox attributes.
|
||||||
|
- Search for router parameter APIs flowing into `fetch`, Axios, navigation, or HTML rendering.
|
||||||
|
- Search for service-worker registration, Cache API writes, worker constructors, Blob URLs, and dynamic imports.
|
||||||
|
- Search for raw HTML sinks and trust escape hatches in every supported frontend framework.
|
||||||
|
- Compare CSP and framing headers across document, API, static, callback, redirect, and error routes.
|
||||||
|
|
||||||
|
## Testing Methodology
|
||||||
|
|
||||||
|
1. **Define the browser state** - Origin/site, context graph, policies, workers, storage, and activation.
|
||||||
|
2. **Identify a source and observable sink** - Message, URL component, cache entry, navigation, load/error event, or implicit call.
|
||||||
|
3. **Trace transformations** - URL parsing, framework decode, browser normalization, request destination, and document replacement.
|
||||||
|
4. **Build paired controls** - Same-origin/cross-origin, status success/error, worker/direct, unique/predictable window name, encoded/raw path.
|
||||||
|
5. **Prove the primitive** - Data transfer, path change, state oracle, cache modification, or context capture.
|
||||||
|
6. **Escalate deliberately** - Chain to a privileged action, sensitive disclosure, SSRF, or executable DOM sink.
|
||||||
|
7. **Cross-browser check** - At minimum record Chromium/Firefox/Safari applicability when the primitive is browser-specific.
|
||||||
|
8. **State interaction requirements** - Click, drag, popup permission, timing window, login state, and visual deception.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
1. Capture the context graph and relevant policies at exploit time.
|
||||||
|
2. Show the exact browser-parsed origin or final request URL, not just the attacker-supplied string.
|
||||||
|
3. For postMessage, prove both message origin and source/context ownership.
|
||||||
|
4. For XS-Leaks, repeat randomized success/failure trials and quantify separation and noise.
|
||||||
|
5. For workers/caches, show which later context consumes the modified data.
|
||||||
|
6. For client-side traversal, capture the final network request and the security-relevant response/action.
|
||||||
|
7. For interaction-dependent chains, provide a screen recording or deterministic event trace.
|
||||||
|
|
||||||
|
## False Positives
|
||||||
|
|
||||||
|
- A message reaches a listener but fails schema, origin, source, or state validation before any action
|
||||||
|
- A router decodes traversal characters but the value never reaches a URL/path sink
|
||||||
|
- Different load/error behavior caused by unstable network rather than protected state
|
||||||
|
- Worker script execution with no sensitive API, shared state, main-thread gadget, or meaningful action
|
||||||
|
- CSP nonce disclosure without a controllable way to reuse it in an executable sink
|
||||||
|
- Named-window collision blocked by origin scoping, randomized names, COOP, or `noopener`
|
||||||
|
- Browser-specific behavior reported without the required version, flag, or user interaction
|
||||||
|
|
||||||
|
## Pro Tips
|
||||||
|
|
||||||
|
1. Treat browsing-context names as attacker-contestable identifiers unless randomized.
|
||||||
|
2. Query parameters are usually decoded automatically; path parameters vary by router and execution context.
|
||||||
|
3. Compare request metadata, not just URLs. Service workers can alter destination/mode semantics.
|
||||||
|
4. A strict origin check does not compensate for attacker control of the supposedly trusted window reference.
|
||||||
|
5. Error pages, redirects, and blocked frames still mutate history and context relationships.
|
||||||
|
6. Keep browser-version claims narrow and retest; these behaviors change faster than server-side primitives.
|
||||||
|
7. Prefer a small state-machine explanation over a large payload catalog.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Browser exploitation is state-machine exploitation. Map origins, context references, policies, workers, storage, navigation, and decoding as one system. Prove each state transition with browser evidence, then chain only the primitives that survive the target's browser and interaction constraints.
|
||||||
@@ -5,7 +5,7 @@ description: HTTP header injection testing covering CRLF / response splitting, c
|
|||||||
|
|
||||||
# HTTP Header Injection
|
# HTTP Header Injection
|
||||||
|
|
||||||
Header injection turns user input into protocol-level control: response splitting, cache poisoning, session fixation, authentication bypass, and request smuggling all trace back to a server-controlled header value that wasn't normalized. The bug usually lives in middle layers — frameworks that copy a request value into a response header, proxies that trust forwarded headers, caches keyed on something the attacker influences. Treat any user-controlled value that reaches a header as code-execution-equivalent until proven otherwise.
|
Header injection turns user input into protocol-level control: response splitting, cache poisoning, session fixation, authentication bypass, and downstream parser confusion can trace back to a server-controlled header value that was not normalized. The bug usually lives in middle layers — frameworks that copy a request value into a response header, proxies that trust forwarded headers, caches keyed on something the attacker influences. Impact depends on which downstream component consumes the injected field and how.
|
||||||
|
|
||||||
## Attack Surface
|
## Attack Surface
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ Header injection turns user input into protocol-level control: response splittin
|
|||||||
|
|
||||||
## Key Vulnerabilities
|
## Key Vulnerabilities
|
||||||
|
|
||||||
### CRLF Response Splitting and Smuggling
|
### CRLF Response Splitting
|
||||||
|
|
||||||
Inject `\r\n\r\n` to terminate the current response and prepend a second attacker-controlled response. Cache or downstream proxy may key on the first response and serve the second to other users.
|
Inject `\r\n\r\n` to terminate the current response and prepend a second attacker-controlled response. Cache or downstream proxy may key on the first response and serve the second to other users.
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ Inject `\r\n\r\n` to terminate the current response and prepend a second attacke
|
|||||||
GET /redirect?to=foo%0d%0aSet-Cookie:%20admin=1%0d%0a%0d%0a<html>poisoned</html> HTTP/1.1
|
GET /redirect?to=foo%0d%0aSet-Cookie:%20admin=1%0d%0a%0d%0a<html>poisoned</html> HTTP/1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Request smuggling is the same primitive at the request layer: inject a header that causes the proxy and backend to disagree on message framing — most commonly conflicting `Content-Length` and `Transfer-Encoding`, or two `Content-Length` headers with different values. Backend reads one request, frontend reads a different one; the leftover bytes become a smuggled request prepended to the next victim's connection.
|
Request smuggling is a separate request-boundary vulnerability involving disagreement between two HTTP parsers, not simply response header injection at the request layer. Load `http_request_smuggling` when conflicting lengths, transfer coding, HTTP/2 downgrades, or connection desynchronization are in scope.
|
||||||
|
|
||||||
### Cache Poisoning
|
### Cache Poisoning
|
||||||
|
|
||||||
@@ -106,16 +106,23 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
- `X-Forwarded-For: 127.0.0.1` to bypass IP allowlists or rate limits keyed on client IP
|
- `X-Forwarded-For: 127.0.0.1` to bypass IP allowlists or rate limits keyed on client IP
|
||||||
- `X-Forwarded-Proto: https` to satisfy "HTTPS-only" checks while still using HTTP
|
- `X-Forwarded-Proto: https` to satisfy "HTTPS-only" checks while still using HTTP
|
||||||
- `X-Forwarded-Host: attacker.tld` for the Host-confusion variants above
|
- `X-Forwarded-Host: attacker.tld` for the Host-confusion variants above
|
||||||
- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same primitive, different header names; spray all of them
|
- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same trust class under different conventions; select evidence-supported variants for the observed proxy/CDN stack
|
||||||
- `X-Original-URL` / `X-Rewrite-URL` (IIS, ASP.NET) — server-side URL rewriting after auth check, classic admin-panel auth bypass
|
- `X-Original-URL` / `X-Rewrite-URL` (IIS, ASP.NET) — server-side URL rewriting after auth check, classic admin-panel auth bypass
|
||||||
|
|
||||||
### Content-Type / Encoding Confusion
|
### Content-Type / Encoding Confusion
|
||||||
|
|
||||||
- Inject `Content-Type: text/html` into an endpoint that returned JSON; browsers may sniff and render → XSS
|
- Inject `Content-Type: text/html` into an endpoint that returned JSON; browsers may sniff and render → XSS
|
||||||
- Inject `charset=utf-7` in `Content-Type` for legacy XSS via UTF-7-encoded payloads
|
|
||||||
- Inject `Content-Disposition: inline` to switch a download into in-page rendering
|
- Inject `Content-Disposition: inline` to switch a download into in-page rendering
|
||||||
- Inject `Content-Encoding: gzip` without actually compressing — clients decode-fail and may reveal raw response bytes in error paths
|
|
||||||
- *Absence* of `X-Content-Type-Options: nosniff` is what enables the sniffing attacks above; the header is a hardening control, not an attack surface — but if a server sets it inconsistently across endpoints, target the ones that don't
|
- *Absence* of `X-Content-Type-Options: nosniff` is what enables the sniffing attacks above; the header is a hardening control, not an attack surface — but if a server sets it inconsistently across endpoints, target the ones that don't
|
||||||
|
- Compare MIME validators with browser parsing of duplicate or comma-joined `Content-Type` values. Record first/last valid member behavior and invalid-parameter recovery for each consumer.
|
||||||
|
|
||||||
|
### Internal Redirect and Handler Confusion
|
||||||
|
|
||||||
|
- Determine whether CGI/FastCGI/WSGI-style response headers can trigger an internal redirect instead of an external response.
|
||||||
|
- Trace which request fields survive the redirect: content type, handler, method, authorization result, path, and environment.
|
||||||
|
- Test whether response metadata is reused as an internal handler, proxy target, template type, or interpreter selection.
|
||||||
|
- Compare direct access controls with the internally dispatched resource. A protected URL may be unreachable directly while the same handler is invokable through a clean internal redirect.
|
||||||
|
- Treat CRLF injection and response-controlling SSRF as possible inputs to this chain, then validate handler selection before using a privileged handler.
|
||||||
|
|
||||||
### XSS via Response Headers
|
### XSS via Response Headers
|
||||||
|
|
||||||
@@ -165,8 +172,9 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
4. **Probe forwarding headers** — spoof `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` against IP-restricted endpoints (admin, rate-limited)
|
4. **Probe forwarding headers** — spoof `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` against IP-restricted endpoints (admin, rate-limited)
|
||||||
5. **Test cache key / response content split** — find inputs that change the body but not the cache key; confirm a second request from a different session sees the poisoned response
|
5. **Test cache key / response content split** — find inputs that change the body but not the cache key; confirm a second request from a different session sees the poisoned response
|
||||||
6. **Test method override** — `X-HTTP-Method-Override` paired with state-changing endpoints reachable via POST or GET
|
6. **Test method override** — `X-HTTP-Method-Override` paired with state-changing endpoints reachable via POST or GET
|
||||||
7. **Test request smuggling pairs** — conflicting `Content-Length` and `Transfer-Encoding`, two `Content-Length` headers, malformed chunked encoding, against any frontend → backend pair
|
7. **Route framing discrepancies** — if evidence indicates request-boundary disagreement, switch to `http_request_smuggling`
|
||||||
8. **Cross-protocol** — replay payloads over HTTP/1.1 and HTTP/2; diff behavior
|
8. **Cross-protocol** — replay payloads over HTTP/1.1 and HTTP/2; diff behavior
|
||||||
|
9. **Trace internal reprocessing** — where response headers can cause subrequests/internal redirects, diff retained fields and final handler selection
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
@@ -174,8 +182,8 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
2. Capture a password-reset / OAuth link pointing at attacker-controlled host — proves Host injection
|
2. Capture a password-reset / OAuth link pointing at attacker-controlled host — proves Host injection
|
||||||
3. Demonstrate the same endpoint returning different auth decisions with and without a forged forwarding header
|
3. Demonstrate the same endpoint returning different auth decisions with and without a forged forwarding header
|
||||||
4. For response splitting: show a downstream cache or proxy serving the injected second response to an unrelated request
|
4. For response splitting: show a downstream cache or proxy serving the injected second response to an unrelated request
|
||||||
5. For request smuggling: show one victim request seeing data from a different request appended (not just timing or single-shot anomaly)
|
5. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation
|
||||||
6. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation
|
6. For internal redirects, capture both the injected response metadata and the final internally selected route/handler
|
||||||
|
|
||||||
## False Positives
|
## False Positives
|
||||||
|
|
||||||
@@ -183,7 +191,6 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
- `X-Forwarded-*` reflected back but only used for logging — not a security boundary, may not be exploitable
|
- `X-Forwarded-*` reflected back but only used for logging — not a security boundary, may not be exploitable
|
||||||
- Browsers blocking `Location: javascript:` or `Location: data:` — capability exists in the protocol but most modern browsers refuse to navigate
|
- Browsers blocking `Location: javascript:` or `Location: data:` — capability exists in the protocol but most modern browsers refuse to navigate
|
||||||
- CRLF appearing in response headers but stripped by an outer proxy before reaching any client or cache
|
- CRLF appearing in response headers but stripped by an outer proxy before reaching any client or cache
|
||||||
- Request smuggling indicators that turn out to be normal pipelining or keep-alive behavior
|
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
@@ -192,7 +199,6 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
- Auth bypass on endpoints trusting forwarding headers
|
- Auth bypass on endpoints trusting forwarding headers
|
||||||
- Session fixation and cookie tossing leading to account hijack
|
- Session fixation and cookie tossing leading to account hijack
|
||||||
- Open redirect for phishing / OAuth `redirect_uri` abuse
|
- Open redirect for phishing / OAuth `redirect_uri` abuse
|
||||||
- Request smuggling — one victim's request reads another victim's response, including auth headers and cookies
|
|
||||||
- WAF / detection bypass via header-name and encoding tricks
|
- WAF / detection bypass via header-name and encoding tricks
|
||||||
|
|
||||||
## Pro Tips
|
## Pro Tips
|
||||||
@@ -200,7 +206,7 @@ The `X-Forwarded-*` family is informational — there is no protocol guarantee a
|
|||||||
1. The fastest win is usually Host / `X-Forwarded-Host` in a password-reset or OAuth flow — try first, costs one request
|
1. The fastest win is usually Host / `X-Forwarded-Host` in a password-reset or OAuth flow — try first, costs one request
|
||||||
2. For cache poisoning, find the *unkeyed* input first (header that influences body but not cache key); the rest follows
|
2. For cache poisoning, find the *unkeyed* input first (header that influences body but not cache key); the rest follows
|
||||||
3. `X-HTTP-Method-Override` is high-yield against backends that route on it before checking method-based auth — most useful from server-side / non-browser callers (it triggers CORS preflight in a browser, so not a CSRF primitive)
|
3. `X-HTTP-Method-Override` is high-yield against backends that route on it before checking method-based auth — most useful from server-side / non-browser callers (it triggers CORS preflight in a browser, so not a CSRF primitive)
|
||||||
4. Smuggling lives at the boundary — identify the proxy → backend pair (CDN → origin, ingress → service) and target the framing disagreement
|
4. If a header test exposes message-boundary disagreement, switch to the dedicated request-smuggling workflow and identify the proxy → backend pair
|
||||||
5. `X-Original-URL` / `X-Rewrite-URL` against IIS / ASP.NET admin endpoints is still a high-yield bypass
|
5. `X-Original-URL` / `X-Rewrite-URL` against IIS / ASP.NET admin endpoints is still a high-yield bypass
|
||||||
6. Before claiming a CRLF win, verify the second line landed as a real header in the cache or downstream consumer — many servers strip CRLF silently
|
6. Before claiming a CRLF win, verify the second line landed as a real header in the cache or downstream consumer — many servers strip CRLF silently
|
||||||
7. Outbound email flows are a separate but related surface — user input flowing into SMTP headers (To, Cc, Subject, Reply-To) is its own injection class with the same root cause
|
7. Outbound email flows are a separate but related surface — user input flowing into SMTP headers (To, Cc, Subject, Reply-To) is its own injection class with the same root cause
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ Insecure deserialization passes attacker-controlled byte streams or structured b
|
|||||||
## Attack Surface
|
## Attack Surface
|
||||||
|
|
||||||
**Formats**
|
**Formats**
|
||||||
- Java: Java native serialization, XStream, JSON → object mappers (Jackson, Fastjson), YAML (SnakeYAML)
|
- Java: Java native serialization, XStream, JSON → object mappers (Jackson, Fastjson), YAML (SnakeYAML), Hessian/Burlap, Kryo
|
||||||
- Python: `pickle`, `yaml.load` (unsafe), `marshal`, shelve
|
- Python: `pickle`, `yaml.load` (unsafe), `marshal`, shelve
|
||||||
- PHP: `unserialize()`, Phar deserialization
|
- PHP: `unserialize()`, Phar deserialization
|
||||||
- .NET: `BinaryFormatter`, `Json.NET TypeNameHandling`, ViewState
|
- .NET: `BinaryFormatter`, `Json.NET TypeNameHandling`, ViewState
|
||||||
- Ruby: `Marshal.load`, YAML.load
|
- Ruby: `Marshal.load`, YAML.load
|
||||||
- Node.js: `node-serialize`, `unserialize.js` (less common; see prototype_pollution for merge bugs)
|
- Node.js: `node-serialize`, `unserialize.js` (less common; see prototype_pollution for merge bugs)
|
||||||
|
|
||||||
|
**Transports and Containers**
|
||||||
|
- Java RMI/JMX, HTTP/RPC endpoints, messaging protocols, queues, signed wrappers, and product-specific binary envelopes can carry one or more formats above
|
||||||
|
|
||||||
**Input Locations**
|
**Input Locations**
|
||||||
- Cookies, session tokens, hidden form fields
|
- Cookies, session tokens, hidden form fields
|
||||||
- API parameters (`data`, `state`, `object`, base64 blobs)
|
- API parameters (`data`, `state`, `object`, base64 blobs)
|
||||||
@@ -58,6 +61,22 @@ yaml.load readObject( TypeNameHandling Marshal.load
|
|||||||
```
|
```
|
||||||
When `enableDefaultTyping` or `@JsonTypeInfo` allows attacker-chosen types.
|
When `enableDefaultTyping` or `@JsonTypeInfo` allows attacker-chosen types.
|
||||||
|
|
||||||
|
**JNDI Pivots from Object Construction**
|
||||||
|
|
||||||
|
JNDI injection is not itself a serialization format. It becomes part of this workflow when an attacker-selected type, setter, or gadget performs `Context.lookup()` during object construction or property population. `JdbcRowSetImpl` and some historical polymorphic JSON chains are examples; Log4j lookups reach JNDI through a different input path and should not be classified as deserialization.
|
||||||
|
|
||||||
|
- Trace fields such as `dataSourceName`, `jndiName`, and `namingURL` into the exact lookup API and provider.
|
||||||
|
- Record the accepted schemes/provider factories (`ldap`, `ldaps`, `rmi`, DNS URL context, or application-specific naming providers). A `dns://` value is not a universal oracle; it works only when the relevant DNS provider and lookup path are present.
|
||||||
|
- Separate network lookup, remote object/reference processing, serialized LDAP attributes, remote codebase loading, and local object-factory invocation. Each is a different capability with different runtime controls.
|
||||||
|
- JEP 290 filters incoming Java serialization graphs; it does not disable JNDI remote codebase loading. JNDI providers gained separate remote-class-loading and serialized-data controls across JDK updates, and current JDKs disable remote code downloading by default. Record the exact JDK build and relevant provider properties instead of using a single “modern Java” rule.
|
||||||
|
- When remote class loading is unavailable, test whether the returned reference can reach a compatible **local** `ObjectFactory`, bean-property path, expression engine, script engine, or other class already present. Confirm exact class names, versions, module access, and trigger methods from the deployed classpath.
|
||||||
|
|
||||||
|
**Hessian / Burlap**
|
||||||
|
- Binary RPC formats deserialized by `HessianInput`/`Hessian2Input`. Attacker object graphs reach gadgets even though it is not native Java serialization.
|
||||||
|
- Treat serializer version, allowed type metadata, constructors/setters invoked, collection/comparator behavior, and classpath as independent prerequisites.
|
||||||
|
- Pair `semantic_confusion` when a proxy or route policy is expected to make the RPC endpoint unreachable.
|
||||||
|
- Inspect the exact deployed libraries rather than relying on generic gadget labels; similar-looking Spring, Resin, Tomcat, XBean, EL, or Groovy classes are not interchangeable.
|
||||||
|
|
||||||
### Python Pickle
|
### Python Pickle
|
||||||
|
|
||||||
Pickle executes arbitrary code during unpickling by design:
|
Pickle executes arbitrary code during unpickling by design:
|
||||||
@@ -162,6 +181,8 @@ When `TypeNameHandling` != `None`.
|
|||||||
3. Check cookies named `JSESSIONID` alternatives, `.ASPXAUTH`, `laravel_session`, custom tokens
|
3. Check cookies named `JSESSIONID` alternatives, `.ASPXAUTH`, `laravel_session`, custom tokens
|
||||||
4. In white-box, trace from `readObject`/`unserialize`/`pickle.loads` backward to source
|
4. In white-box, trace from `readObject`/`unserialize`/`pickle.loads` backward to source
|
||||||
5. ViewState MAC off is still common on legacy ASP.NET — test early on `.aspx` apps
|
5. ViewState MAC off is still common on legacy ASP.NET — test early on `.aspx` apps
|
||||||
|
6. Model JNDI lookup, reference/object processing, remote codebase loading, and local factory invocation as separate stages
|
||||||
|
7. A "blocked" enterprise deserialization endpoint may still be reachable through a proxy/path-normalization mismatch — pair `semantic_confusion`
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
||||||
@@ -172,6 +193,7 @@ Payload generation is the practitioner's core tool here. The sandbox has `git`/`
|
|||||||
| **ysoserial** (frohoff) | Java native | Gadget-chain payloads: `CommonsCollections1-7`, `Groovy1`, `Spring1/2`, and `URLDNS` for a safe no-exec DNS oracle. Needs a JRE. |
|
| **ysoserial** (frohoff) | Java native | Gadget-chain payloads: `CommonsCollections1-7`, `Groovy1`, `Spring1/2`, and `URLDNS` for a safe no-exec DNS oracle. Needs a JRE. |
|
||||||
| **phpggc** (ambionics) | PHP `unserialize` / Phar | Framework POP chains (Laravel, Symfony, WordPress, Drupal, Monolog). Needs `php-cli`. |
|
| **phpggc** (ambionics) | PHP `unserialize` / Phar | Framework POP chains (Laravel, Symfony, WordPress, Drupal, Monolog). Needs `php-cli`. |
|
||||||
| **ysoserial.net** | .NET `BinaryFormatter` / Json.NET | Windows/.NET gadget payloads. Needs .NET/mono — usually out of scope in a Linux sandbox. |
|
| **ysoserial.net** | .NET `BinaryFormatter` / Json.NET | Windows/.NET gadget payloads. Needs .NET/mono — usually out of scope in a Linux sandbox. |
|
||||||
|
| **marshalsec** | Java Hessian/Burlap, Kryo, JSON, and JNDI reference tooling | Use only from a reviewed, pinned upstream commit when a non-native Java marshaller requires it. It has no stable release and intentionally bundles historical gadget dependencies; do not treat it as a globally installed default tool. |
|
||||||
|
|
||||||
```
|
```
|
||||||
# Java: prove the sink with a no-exec DNS oracle BEFORE any RCE chain
|
# Java: prove the sink with a no-exec DNS oracle BEFORE any RCE chain
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
|||||||
|
|
||||||
- Double extensions: avatar.jpg.php, report.pdf.html; mixed casing: .pHp, .PhAr
|
- Double extensions: avatar.jpg.php, report.pdf.html; mixed casing: .pHp, .PhAr
|
||||||
- Magic-byte spoofing: valid JPEG header then embedded script; verify server uses content inspection, not extensions alone
|
- Magic-byte spoofing: valid JPEG header then embedded script; verify server uses content inspection, not extensions alone
|
||||||
|
- Detector/consumer differential: make the upload validator and the later parser disagree about type, structure, or validity
|
||||||
|
- Probe detector scan windows, recursion/nesting limits, maximum bytes inspected, invalid-syntax recovery, and version-specific magic databases
|
||||||
|
|
||||||
### Archive Attacks
|
### Archive Attacks
|
||||||
|
|
||||||
@@ -120,6 +122,8 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
|||||||
- Client-side only checks; relying on JS/MIME provided by browser
|
- Client-side only checks; relying on JS/MIME provided by browser
|
||||||
- Trusting multipart boundary part headers blindly
|
- Trusting multipart boundary part headers blindly
|
||||||
- Extension allowlists without server-side content inspection
|
- Extension allowlists without server-side content inspection
|
||||||
|
- One parser validates metadata or leading bytes while another parser processes the full file
|
||||||
|
- Type-detection wrappers assumed identical even when they bundle different library/database versions
|
||||||
|
|
||||||
### Evasion Tricks
|
### Evasion Tricks
|
||||||
|
|
||||||
@@ -146,8 +150,9 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
|||||||
1. **Map the pipeline** - Client → ingress → storage → processors → serving. Note where validation and auth occur
|
1. **Map the pipeline** - Client → ingress → storage → processors → serving. Note where validation and auth occur
|
||||||
2. **Identify allowed types** - Size limits, filename rules, storage keys, and who serves the content
|
2. **Identify allowed types** - Size limits, filename rules, storage keys, and who serves the content
|
||||||
3. **Collect baselines** - Capture resulting URLs and headers for legitimate uploads
|
3. **Collect baselines** - Capture resulting URLs and headers for legitimate uploads
|
||||||
4. **Exercise bypass families** - Extension games, MIME/content-type, magic bytes, polyglots, metadata payloads, archive structure
|
4. **Map validators and consumers** - Identify the detector/library/version when possible and every later parser, converter, renderer, or browser context
|
||||||
5. **Validate execution** - Can uploaded content execute on server or client?
|
5. **Exercise bypass families** - Extension games, MIME/content-type, magic bytes, parser limits, polyglots, metadata payloads, archive structure
|
||||||
|
6. **Validate execution** - Prove the accepted object reaches a more privileged consumer and can execute or render active content
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
@@ -182,6 +187,7 @@ Upload surfaces are high risk: server-side execution (RCE), stored XSS, malware
|
|||||||
8. When you cannot get execution, aim for stored XSS or header-driven script execution
|
8. When you cannot get execution, aim for stored XSS or header-driven script execution
|
||||||
9. Validate that CDNs honor attachment/nosniff
|
9. Validate that CDNs honor attachment/nosniff
|
||||||
10. Document full pipeline behavior per asset type
|
10. Document full pipeline behavior per asset type
|
||||||
|
11. Reproduce detector/consumer mismatches on the deployed library versions; OS packages and language bindings may ship different limits
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
|
|
||||||
**Path Traversal**
|
**Path Traversal**
|
||||||
- Read files outside intended roots via `../`, encoding, normalization gaps
|
- Read files outside intended roots via `../`, encoding, normalization gaps
|
||||||
|
- Write or create files outside intended roots, then evaluate framework-controlled resolution paths separately from direct web access
|
||||||
|
|
||||||
**Local File Inclusion (LFI)**
|
**Local File Inclusion (LFI)**
|
||||||
- Include server-side files into interpreters/templates
|
- Include server-side files into interpreters/templates
|
||||||
@@ -51,7 +52,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
### Capability Probes
|
### Capability Probes
|
||||||
|
|
||||||
- Path traversal baseline: `../../etc/hosts` and `C:\Windows\win.ini`
|
- Path traversal baseline: `../../etc/hosts` and `C:\Windows\win.ini`
|
||||||
- Encodings: `%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `..%5c`, mixed UTF-8 (`%c0%2e`), Unicode dots and slashes
|
- Encodings: `%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `..%5c`, and Unicode lookalikes only where a documented conversion layer maps them to path syntax
|
||||||
- Normalization tests: `..../`, `..\\`, `././`, trailing dot/double dot segments; repeated decoding
|
- Normalization tests: `..../`, `..\\`, `././`, trailing dot/double dot segments; repeated decoding
|
||||||
- Absolute path acceptance: `/etc/passwd`, `C:\Windows\System32\drivers\etc\hosts`
|
- Absolute path acceptance: `/etc/passwd`, `C:\Windows\System32\drivers\etc\hosts`
|
||||||
- Server mismatch: `/static/..;/../etc/passwd` ("..;"), encoded slashes (`%2F`), double-decoding via upstream
|
- Server mismatch: `/static/..;/../etc/passwd` ("..;"), encoded slashes (`%2F`), double-decoding via upstream
|
||||||
@@ -69,7 +70,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
|
|
||||||
### OAST
|
### OAST
|
||||||
|
|
||||||
- RFI/LFI with wrappers that trigger outbound fetches (HTTP/DNS) to confirm inclusion/execution
|
- For RFI or URL-capable resource loaders, a correlated callback confirms server-side resolution/fetch. It does not by itself prove inclusion or execution; use a separate response or side-effect oracle for that claim.
|
||||||
|
|
||||||
### Side Effects
|
### Side Effects
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
### Path Traversal Bypasses
|
### Path Traversal Bypasses
|
||||||
|
|
||||||
**Encodings**
|
**Encodings**
|
||||||
- Single/double URL-encoding, mixed case, overlong UTF-8, UTF-16, path normalization oddities
|
- Single/double URL-encoding, mixed case, UTF-16 or Unicode conversion only when present in the stack, and path normalization oddities
|
||||||
|
|
||||||
**Mixed Separators**
|
**Mixed Separators**
|
||||||
- `/` and `\\` on Windows; `//` and `\\\\` collapse differences across frameworks
|
- `/` and `\\` on Windows; `//` and `\\\\` collapse differences across frameworks
|
||||||
@@ -147,13 +148,38 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
- Verify symlink handling and path canonicalization prior to write
|
- Verify symlink handling and path canonicalization prior to write
|
||||||
- Impact: overwrite config/templates or drop webshells into served directories
|
- Impact: overwrite config/templates or drop webshells into served directories
|
||||||
|
|
||||||
|
### File Write to Execution
|
||||||
|
|
||||||
|
Characterize the write primitive before choosing a payload:
|
||||||
|
|
||||||
|
- create vs overwrite vs append; atomic replace vs streamed write
|
||||||
|
- absolute vs relative path; controllable directory, filename, extension, and bytes
|
||||||
|
- text encoding, newline conversion, templating, compression, or report generation applied before write
|
||||||
|
- target process permissions and whether symlinks are followed
|
||||||
|
- immediate load, hot reload, cache invalidation, restart, scheduled task, or user action required
|
||||||
|
|
||||||
|
Then inventory generic execution and influence surfaces:
|
||||||
|
|
||||||
|
- view/template search paths and implicit rendering
|
||||||
|
- module, controller, plugin, package, or class autoload directories
|
||||||
|
- application bootstrap files and language package initializers
|
||||||
|
- server/user configuration that changes handler or interpreter behavior
|
||||||
|
- job definitions, hooks, startup scripts, cron/task inputs, and CI workspace files
|
||||||
|
- logs, sessions, caches, generated sources, and compiled-template directories later included or evaluated
|
||||||
|
|
||||||
|
Do not require the malicious file to be directly web-accessible. An HTTP extension allowlist can block `/path/payload.ext` while an internal view engine, autoloader, or interpreter still opens and executes that file through a clean route. Trace public request filtering and internal file resolution as separate security boundaries.
|
||||||
|
|
||||||
|
Test search order with candidate marker files or filesystem traces. Trigger the normal route/action that causes internal resolution. Record whether the framework creates, compiles, caches, or executes the artifact and what reload condition is required.
|
||||||
|
|
||||||
## Testing Methodology
|
## Testing Methodology
|
||||||
|
|
||||||
1. **Inventory file operations** - Downloads, previews, templates, logs, exports/imports, report engines, uploads, archive extractors
|
1. **Inventory file operations** - Downloads, previews, templates, logs, exports/imports, report engines, uploads, archive extractors
|
||||||
2. **Identify input joins** - Path joins (base + user), include/require/template loads, resource fetchers, archive extract destinations
|
2. **Identify input joins** - Path joins (base + user), include/require/template loads, resource fetchers, archive extract destinations
|
||||||
3. **Probe normalization** - Separators, encodings, double-decodes, case, trailing dots/slashes
|
3. **Probe normalization** - Separators, encodings, double-decodes, case, trailing dots/slashes
|
||||||
4. **Compare behaviors** - Web server vs application behavior
|
4. **Compare behaviors** - Web server vs application behavior
|
||||||
5. **Escalate** - From disclosure (read) to influence (write/extract/include), then to execution (wrapper/engine chains)
|
5. **Characterize writes** - Determine create/overwrite/append, path and byte control, permissions, and reload/trigger conditions
|
||||||
|
6. **Map resolvers** - Test template/view search paths, autoloaders, plugins, configs, jobs, and other internal consumers separately from direct file serving
|
||||||
|
7. **Escalate** - From disclosure (read) to influence (write/extract/include), then to execution through a proven resolver or interpreter
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
@@ -161,7 +187,8 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
2. For LFI, demonstrate inclusion of a benign local file or harmless wrapper output (`php://filter` base64 of index.php)
|
2. For LFI, demonstrate inclusion of a benign local file or harmless wrapper output (`php://filter` base64 of index.php)
|
||||||
3. For RFI, prove remote fetch by OAST or controlled output; avoid destructive payloads
|
3. For RFI, prove remote fetch by OAST or controlled output; avoid destructive payloads
|
||||||
4. For Zip Slip, create an archive with `../` entries and show write outside target (e.g., marker file read back)
|
4. For Zip Slip, create an archive with `../` entries and show write outside target (e.g., marker file read back)
|
||||||
5. Provide before/after file paths, exact requests, and content hashes/lengths for reproducibility
|
5. For file-write chains, first prove a canary is created at the intended path, then prove the normal resolver loads it; document cache/reload requirements
|
||||||
|
6. Provide before/after file paths, exact requests, and content hashes/lengths for reproducibility
|
||||||
|
|
||||||
## False Positives
|
## False Positives
|
||||||
|
|
||||||
@@ -184,6 +211,7 @@ Improper file path handling and dynamic inclusion enable sensitive file disclosu
|
|||||||
3. For LFI, prefer `php://filter` base64 probes over destructive payloads; enumerate readable logs and sessions
|
3. For LFI, prefer `php://filter` base64 probes over destructive payloads; enumerate readable logs and sessions
|
||||||
4. Validate extraction code with synthetic archives; include symlinks and deep `../` chains
|
4. Validate extraction code with synthetic archives; include symlinks and deep `../` chains
|
||||||
5. Use minimal PoCs and hard evidence (hashes, paths). Avoid noisy DoS against filesystems
|
5. Use minimal PoCs and hard evidence (hashes, paths). Avoid noisy DoS against filesystems
|
||||||
|
6. When direct execution is blocked, enumerate internal search paths before assuming the write is low impact
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
name: semantic-confusion
|
||||||
|
description: Cross-component semantic confusion testing for parser differentials, normalization mismatches, overloaded fields, lifecycle state drift, internal redirects, protocol translation, and validator-to-sink inconsistencies
|
||||||
|
---
|
||||||
|
|
||||||
|
# Semantic Confusion
|
||||||
|
|
||||||
|
Use this skill when two or more components consume the same attacker-influenced value. The central question is not merely whether input is validated, but whether every consumer assigns the same meaning to the value at the moment it makes a security decision.
|
||||||
|
|
||||||
|
Typical chains cross a validator, router, proxy, framework, parser, filesystem, interpreter, cache, or browser. A value can be safe in one representation and dangerous after a later decode, normalization, fallback, or field mutation.
|
||||||
|
|
||||||
|
## Authorization and Safety Boundary
|
||||||
|
|
||||||
|
- Run active differentials only against explicit authorized targets. Preserve destination allowlists and set request, rate, body, response, timeout, and retry ceilings.
|
||||||
|
- Perform malformed framing, delayed-body, oversized-input, crash, or resource-exhaustion cases only in a restartable isolated lab with health monitoring.
|
||||||
|
- Use synthetic canaries, reversible actions, non-secret protected resources, or a constant per-test callback identifier. Never place target-derived secrets in an OAST label/body.
|
||||||
|
- Change one representation axis at a time so the security-relevant disagreement remains attributable to a specific boundary.
|
||||||
|
- Pair `browser_security` when the final consumer is a browser context, worker, cache, or navigation state machine.
|
||||||
|
- Do not load this skill for pure ownership drift where every component resolves and interprets the name consistently; use `infrastructure_lifecycle` unless a representation, alias, identity, or resolution-result mismatch is present.
|
||||||
|
|
||||||
|
## Core Model
|
||||||
|
|
||||||
|
Build a transformation graph before spraying payloads:
|
||||||
|
|
||||||
|
```text
|
||||||
|
raw bytes
|
||||||
|
-> transport parser
|
||||||
|
-> proxy / middleware representation
|
||||||
|
-> authorization or validation decision
|
||||||
|
-> rewrite / decode / normalization
|
||||||
|
-> internal redirect or dispatch
|
||||||
|
-> final sink interpretation
|
||||||
|
```
|
||||||
|
|
||||||
|
For every edge, record:
|
||||||
|
|
||||||
|
- exact input representation: bytes, string, URL, path, header list, object, or structured field
|
||||||
|
- owning component and implementation/version
|
||||||
|
- transformation performed, including error and fallback behavior
|
||||||
|
- security decision made before or after the transformation
|
||||||
|
- whether the original and transformed values remain available simultaneously
|
||||||
|
- whether a field changes semantic type, such as filename to URL or MIME type to handler
|
||||||
|
|
||||||
|
The highest-signal condition is `security_check(value_A)` followed by `sink(transform(value_A))` where the checked and consumed representations are not equivalent.
|
||||||
|
|
||||||
|
## High-Value Confusion Classes
|
||||||
|
|
||||||
|
### Parser Differentials
|
||||||
|
|
||||||
|
- Compare browser, framework, proxy, library, and backend parsing of the exact same bytes.
|
||||||
|
- Test duplicate and comma-joined fields, first-match vs last-match behavior, invalid-token recovery, comments, quoting, and empty members.
|
||||||
|
- Include structured formats and metadata: URL, MIME, JSON, multipart, XML, cookies, forwarded headers, and serialized objects.
|
||||||
|
- Treat leniency as a security feature only when every downstream consumer is equally lenient in the same way.
|
||||||
|
|
||||||
|
### Normalization and Canonicalization Drift
|
||||||
|
|
||||||
|
- Map percent-decoding count, Unicode conversion, slash/backslash handling, dot-segment removal, case folding, IDNA, numeric IP conversion, and filesystem cleanup.
|
||||||
|
- Compare string-prefix checks with segment-aware or origin-aware comparisons.
|
||||||
|
- Test malformed Unicode and replacement behavior; a rejected code point may become an allowed delimiter or wildcard later.
|
||||||
|
- Test path, query, and fragment separately. Browsers and routers commonly transform each source differently.
|
||||||
|
|
||||||
|
### Field and Type Overloading
|
||||||
|
|
||||||
|
- Identify shared fields reused for different concepts: path vs URL, content type vs handler, display name vs executable name, route vs filesystem location.
|
||||||
|
- Trace every writer and reader of the field across the complete lifecycle.
|
||||||
|
- Look for implicit fallback: when the intended field is empty, another field becomes authoritative.
|
||||||
|
- Exercise fields after errors, rewrites, subrequests, retries, internal redirects, and protocol upgrades/downgrades.
|
||||||
|
|
||||||
|
### Lifecycle and State Drift
|
||||||
|
|
||||||
|
- Trigger error paths that should terminate processing and verify that later phases actually stop.
|
||||||
|
- Look for stale metadata copied into a new request, subrequest, background job, cache entry, or retry.
|
||||||
|
- Compare direct external access with internal dispatch. Edge controls may inspect the public URL while an internal resolver opens a different path or invokes a different handler.
|
||||||
|
- Test order-dependent behavior: validation before rewrite, auth before route normalization, or content classification before processing.
|
||||||
|
|
||||||
|
### Boundary Translation
|
||||||
|
|
||||||
|
- Map HTTP/2 to HTTP/1 translation, proxy to application rewriting, URL to filesystem resolution, upload detector to content consumer, and client router to API request construction.
|
||||||
|
- In a restartable lab and only when supported by evidence, vary framing, bounded delays/body sizes, content type, pseudo-headers, and method conversion. Check target health after resource-sensitive cases.
|
||||||
|
- Do not assume a WAF or authorization sidecar sees the full body or final normalized request.
|
||||||
|
|
||||||
|
### Namespace and Resolution Fallback
|
||||||
|
|
||||||
|
- Identify names resolved across multiple scopes: local path, environment `PATH`, cache, private registry, public registry, plugin directory, template search path, or autoloader.
|
||||||
|
- Record lookup order and what happens when the intended entry is missing.
|
||||||
|
- Compare protected package/module names with exposed command, binary, handler, or alias names. For npm, a scoped package can expose an unscoped `bin` name, so the protected package name and invoked executable may differ.
|
||||||
|
- Treat automatic remote fallback or search-path fallback as an execution boundary.
|
||||||
|
- Load `npx_confusion` when `npx` or `npm exec` may reinterpret a missing executable as a public package spec.
|
||||||
|
|
||||||
|
## Reconnaissance
|
||||||
|
|
||||||
|
### Black-Box Mapping
|
||||||
|
|
||||||
|
1. Capture a clean baseline with raw request and response bytes.
|
||||||
|
2. Change one representation axis at a time: encoding depth, delimiter, duplicate, separator, method, protocol, body framing, or Unicode form.
|
||||||
|
3. Diff status, headers, body digest/length, timing, redirects, cache state, and out-of-band callbacks.
|
||||||
|
4. Replay through different paths: direct origin vs CDN, HTTP/1.1 vs HTTP/2, public route vs alternate host, synchronous vs background processing.
|
||||||
|
5. Cluster responses by behavior before escalating. Small differentials reveal component boundaries.
|
||||||
|
|
||||||
|
### Source-Aware Mapping
|
||||||
|
|
||||||
|
- Find every read and write of shared request/context fields, not just the obvious sink.
|
||||||
|
- Trace route matching, auth middleware, rewrites, internal redirects, handler selection, and response generation in execution order.
|
||||||
|
- Inventory decode/parse/normalize calls and note whether return values or errors are ignored.
|
||||||
|
- Search for compatibility fallbacks, legacy aliases, permissive recovery, default handlers, and search-path iteration.
|
||||||
|
- Inspect packaging and deployment defaults; distro configuration, enabled modules, plugins, and symlinks often determine reachability.
|
||||||
|
|
||||||
|
## Differential Test Matrix
|
||||||
|
|
||||||
|
Build a bounded matrix from relevant axes instead of blindly combining everything:
|
||||||
|
|
||||||
|
| Axis | Representative variants |
|
||||||
|
|---|---|
|
||||||
|
| Encoding | raw, once encoded, twice encoded, mixed case, malformed Unicode |
|
||||||
|
| Structure | duplicate, comma-joined, empty member, quoted, comment-like suffix |
|
||||||
|
| Path | `/`, `\\`, `//`, dot segments, absolute, sibling-prefix collision |
|
||||||
|
| URL | userinfo, numeric IP, alternate IP radix, trailing dot, fragment/query split |
|
||||||
|
| Transport | HTTP/1.1, HTTP/2, chunked/fixed body, delayed DATA, oversized body |
|
||||||
|
| Lifecycle | normal, error, retry, internal redirect, cache hit, background worker |
|
||||||
|
| Consumer | edge, application, library, filesystem, interpreter, browser |
|
||||||
|
|
||||||
|
Select axes supported by evidence from the target. Record which component saw which representation.
|
||||||
|
|
||||||
|
### Repeatable Harnesses
|
||||||
|
|
||||||
|
- For two local parsers, canonicalizers, or validator/consumer functions, load `hypothesis` and express the expected relationship as a property. Bound sizes/examples and keep the minimized disagreement as a regression test.
|
||||||
|
- For an ordered HTTP flow with cookies, redirects, captured values, and assertions, load `hurl` and encode vulnerable, fixed, and negative-control environments using the same request chain.
|
||||||
|
- Use raw-byte or protocol-specific harnesses when a high-level HTTP client would normalize the ambiguity away.
|
||||||
|
- Separate input generation from transport. Generators that are safe against pure local functions become active fuzzers when connected to a live target.
|
||||||
|
|
||||||
|
## Chaining Strategy
|
||||||
|
|
||||||
|
Treat the first differential as a primitive, then ask what authority the later consumer has:
|
||||||
|
|
||||||
|
- auth or ACL bypass -> protected route or file
|
||||||
|
- path/URL confusion -> source disclosure, SSRF, local socket, or unintended handler
|
||||||
|
- detector/consumer mismatch -> active upload processing or inline browser execution
|
||||||
|
- internal redirect state carryover -> handler selection or policy bypass
|
||||||
|
- search-path or namespace fallback -> attacker-controlled code resolution
|
||||||
|
- browser/router decode -> client-side path traversal, CSRF-like action, SSRF, or XSS sink
|
||||||
|
|
||||||
|
Enumerate existing local gadgets only after the primitive is proven. Prefer generic classes such as interpreters, template engines, debug tools, package scripts, local sockets, and autoload paths over a vendor-specific file list.
|
||||||
|
|
||||||
|
## Testing Methodology
|
||||||
|
|
||||||
|
1. **Define the invariant** - State what all components are expected to agree on: origin, path, type, handler, identity, length, or package name.
|
||||||
|
2. **Draw the graph** - List consumers and transformations in real execution order.
|
||||||
|
3. **Locate early decisions** - Mark validation, auth, WAF, cache, and routing checks.
|
||||||
|
4. **Locate late meaning changes** - Mark decodes, rewrites, fallback, internal dispatch, and sink parsing.
|
||||||
|
5. **Build a focused matrix** - Exercise only transformations supported by the stack.
|
||||||
|
6. **Isolate the disagreement** - Produce paired inputs that differ at one boundary and explain both interpretations.
|
||||||
|
7. **Prove the primitive safely** - Use a synthetic protected canary, reversible marker, constant callback identifier, or no-op handler whose behavior and side effects are understood.
|
||||||
|
8. **Escalate by capability** - Track Read -> influence -> write -> dispatch -> execute transitions with evidence and prerequisites for every edge.
|
||||||
|
9. **Cross-check versions/configurations** - Reproduce on a fixed version or hardened configuration when possible.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
A valid confusion finding should include:
|
||||||
|
|
||||||
|
1. the exact bytes or structured input supplied
|
||||||
|
2. the representation observed by the security control
|
||||||
|
3. the different representation observed by the final consumer
|
||||||
|
4. the transformation or lifecycle event that created the difference
|
||||||
|
5. paired control and exploit results across repeat runs
|
||||||
|
6. version, protocol, configuration, and interaction prerequisites
|
||||||
|
7. a minimal impact proof that does not depend on unrelated undefined behavior
|
||||||
|
|
||||||
|
## False Positives
|
||||||
|
|
||||||
|
- Different error messages with identical final authorization and sink behavior
|
||||||
|
- A parser accepts odd syntax but downstream consumers preserve the same safe meaning
|
||||||
|
- A normalization difference visible only in logs, with no security decision between representations
|
||||||
|
- WAF bypass where the application itself rejects the request identically
|
||||||
|
- Version-specific behavior claimed as universal without testing the relevant deployment
|
||||||
|
- A search-path candidate that is attacker-named but cannot be created, claimed, loaded, or executed
|
||||||
|
|
||||||
|
## Pro Tips
|
||||||
|
|
||||||
|
1. Begin with relationships and shared state, not endpoint payload lists.
|
||||||
|
2. Preserve raw traffic; high-level clients often normalize away the exploit before sending it.
|
||||||
|
3. Error paths are alternate lifecycles. Verify which fields survive and which phases still execute.
|
||||||
|
4. Compare direct and internal access separately; ingress policy rarely governs framework file IO or handler dispatch.
|
||||||
|
5. When a prefix allowlist is used, test a sibling sharing the prefix and verify with a segment-aware comparison.
|
||||||
|
6. Distinguish presence, reachability, and impact. Each needs separate evidence.
|
||||||
|
7. Generalize a finding by naming the disagreement class, not by copying its final payload.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Semantic confusion exists when a security decision and a privileged consumer disagree about the meaning of the same attacker-influenced data. Model the entire transformation lifecycle, isolate one disagreement at a time, and prove both interpretations. The reusable unit is the boundary and its invariant—not a CVE-specific string.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""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
|
||||||
|
import re
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# A tool name offered to a model has to be letters, digits, underscores or
|
||||||
|
# hyphens; anything else is rejected outright by the model APIs. Three things can
|
||||||
|
# put a stray character in one: the separator between the connection and the tool
|
||||||
|
# name, a name the server chose for its own tool (servers commonly namespace
|
||||||
|
# theirs), and the connection name out of the user's config file. Sanitizing the
|
||||||
|
# finished name covers all three rather than only the separator.
|
||||||
|
_INVALID_TOOL_NAME_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _namespaced_tool_name(connection: str, tool: str) -> str:
|
||||||
|
"""The name a connection's tool is offered to the model under.
|
||||||
|
|
||||||
|
Only the model-facing name is rewritten. Every call to the server uses the
|
||||||
|
tool name the server itself reported, so sanitizing here can never change
|
||||||
|
which tool is invoked.
|
||||||
|
"""
|
||||||
|
return _INVALID_TOOL_NAME_CHARS.sub("_", f"{connection}_{tool}")
|
||||||
|
|
||||||
|
|
||||||
|
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 = _namespaced_tool_name(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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""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))
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
"""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 re
|
||||||
|
|
||||||
|
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_registered_names_are_valid_tool_names() -> None:
|
||||||
|
# Model APIs reject a tool name containing anything but letters, digits,
|
||||||
|
# underscores and hyphens, and reject the whole request rather than the one
|
||||||
|
# tool. A server naming its own tools with dots, or a connection named with
|
||||||
|
# a space in the user's config, must not be able to break a run.
|
||||||
|
server = FakeMCPServer("my server", [_mcp_tool("db.query"), _mcp_tool("ok_tool")])
|
||||||
|
|
||||||
|
await _register_server_tools(_config("my server", None), server)
|
||||||
|
|
||||||
|
names = _registered_names()
|
||||||
|
assert names == ["my_server_db_query", "my_server_ok_tool"]
|
||||||
|
assert all(re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", name) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_rename_does_not_change_which_tool_is_called() -> None:
|
||||||
|
# Only the model-facing name is sanitized; the server is always asked for the
|
||||||
|
# tool name it reported.
|
||||||
|
server = FakeMCPServer("my server", [_mcp_tool("db.query")])
|
||||||
|
|
||||||
|
tools = await _register_server_tools(_config("my server", None), server)
|
||||||
|
|
||||||
|
assert tools[0].name == "my_server_db_query"
|
||||||
|
await tools[0].on_invoke_tool(None, "{}")
|
||||||
|
assert server.calls == [("db.query", {})]
|
||||||
|
|
||||||
|
|
||||||
|
@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"]
|
||||||
+49
-7
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
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.server import serve
|
||||||
from strix.interface.viewer.transcript import (
|
from strix.interface.viewer.transcript import (
|
||||||
build_run_state,
|
build_run_state,
|
||||||
@@ -48,6 +49,31 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
|
|||||||
assert runs_base_dir() == tmp_path / "strix_runs"
|
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(
|
def test_latest_run_dir_picks_newest_by_record_mtime(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -173,14 +199,15 @@ 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")
|
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
|
||||||
|
|
||||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
httpd, url, token = serve(run_dir, open_browser=False)
|
||||||
try:
|
try:
|
||||||
status, ctype, body = _get(f"{url}/api/run")
|
cookie = _session_cookie(url, token)
|
||||||
|
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert "application/json" in ctype
|
assert "application/json" in ctype
|
||||||
assert json.loads(body)["finished"] is True
|
assert json.loads(body)["finished"] is True
|
||||||
|
|
||||||
status, _, body = _get(f"{url}/api/transcript")
|
status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
|
||||||
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
|
||||||
|
|
||||||
# Real asset is served.
|
# Real asset is served.
|
||||||
@@ -429,6 +456,22 @@ def test_unauthorized_client_cannot_acquire_capability(
|
|||||||
httpd.server_close()
|
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:
|
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)
|
run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
|
||||||
_bundle(tmp_path, monkeypatch)
|
_bundle(tmp_path, monkeypatch)
|
||||||
@@ -561,11 +604,10 @@ def test_historical_run_data_requires_verification(
|
|||||||
|
|
||||||
httpd, url, token = serve(launched, open_browser=False)
|
httpd, url, token = serve(launched, open_browser=False)
|
||||||
try:
|
try:
|
||||||
# The launched run is always viewable, no verification and no cookie.
|
# The launched run needs the session capability, but not email verification.
|
||||||
status, _, _ = _get(f"{url}/api/run")
|
assert _get_status(f"{url}/api/run") == 403
|
||||||
assert status == 200
|
|
||||||
|
|
||||||
cookie = _session_cookie(url, token)
|
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
|
# A different run needs the session capability first: a cookie-less
|
||||||
# caller is forbidden even once the machine is verified.
|
# caller is forbidden even once the machine is verified.
|
||||||
|
|||||||
Reference in New Issue
Block a user