mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(mcp): redact endpoint credentials fail-closed in every display, preview and export surface
One structural rule (secrets.RedactEndpoint / CredentialKey / RedactArgs) replaces mcpsetup.RedactURL's query-only masking. Userinfo and fragments are dropped or masked, query and fragment keys are decoded before classification, unparseable endpoints render as <redacted>, and transport diagnostics keep error identity while omitting peer-supplied prose. Operational values (install, approval fingerprints, persistence) are untouched; preview and plan responses add separate display fields that the desktop mirror and confirmation UI render. Rebased onto the launcher-aware NameFromArgv: names derive from the original argv but never from a value the display projection masked, so build-flag values such as `go run -exec` still resolve. Install classification reads the typed HTTP status instead of sniffing the server name out of the message. Fixes #11785
This commit is contained in:
1 parent
7aeae1990d
commit
0da518ce84
79 files changed
+2435
-282
No files matched your search
@@ -87,6 +87,11 @@ export interface McpDraftServer {
|
||||
url?: string;
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
displayUrl?: string;
|
||||
displayEnv?: Record<string, string>;
|
||||
displayHeaders?: Record<string, string>;
|
||||
displayCommand?: string;
|
||||
displayArgs?: string[];
|
||||
}
|
||||
|
||||
// What the confirmation card must show: shell is the command that will run,
|
||||
|
||||
@@ -137,7 +137,7 @@ function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
|
||||
.map((k) => (
|
||||
<div className="risk" key={k.field} data-kind={k.kind}>
|
||||
<span className="lb">{t(KIND_LABEL[k.kind] ?? k.kind)}</span>
|
||||
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.detail}</span>
|
||||
<span className="dt">{k.kind === "secret" ? k.field.split(".").pop() : k.kind === "unknown-host" ? (s.displayUrl ?? "<redacted>") : [s.displayCommand ?? "<redacted>", ...(s.displayArgs ?? [])].join(" ")}</span>
|
||||
{k.kind === "secret" && <span className="why">{k.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { AddServer } from "./AddServer";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders the display endpoint and installs the operational endpoint", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const server = {
|
||||
name: "neutral", transport: "http",
|
||||
url: "https://host/mcp?token=fixture-secret",
|
||||
displayUrl: "https://host/mcp?token=%3Credacted%3E",
|
||||
env: { PASSWORD: "fixture-secret" }, displayEnv: { PASSWORD: "<redacted>" },
|
||||
headers: { Authorization: "fixture-secret" }, displayHeaders: { Authorization: "<redacted>" },
|
||||
};
|
||||
const stdio = { name: "neutral-stdio", transport: "stdio", command: "node", args: ["--password", "fixture-secret"], displayCommand: "node", displayArgs: ["--password", "<redacted>"] };
|
||||
vi.spyOn(port, "parseMcp").mockResolvedValue({ servers: [server, stdio], risks: [
|
||||
{ server: "neutral", kind: "unknown-host", field: "url", detail: "obsolete endpoint" },
|
||||
{ server: "neutral-stdio", kind: "shell", field: "command", detail: "node --password fixture-secret" },
|
||||
] });
|
||||
const install = vi.spyOn(port, "installMcp").mockResolvedValue({ name: "neutral", state: "ready", action: "installed", toolCount: 0, message: "" });
|
||||
render(<AddServer port={port} canProject onClose={() => {}} onInstalled={() => {}} />);
|
||||
await userEvent.type(screen.getByRole("textbox"), "neutral fixture");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
expect(screen.getByText(server.displayUrl)).toBeTruthy();
|
||||
expect(screen.getByText("node --password <redacted>")).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("fixture-secret");
|
||||
await userEvent.click(screen.getByRole("button", { name: "接入" }));
|
||||
expect(install).toHaveBeenNthCalledWith(1, server, "user");
|
||||
expect(install).toHaveBeenNthCalledWith(2, stdio, "user");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-03
|
||||
---
|
||||
|
||||
# MCP credential projection
|
||||
|
||||
Operational config remains the authority for installation, approval hashing,
|
||||
persistence and connections. Display and export consume copies.
|
||||
|
||||
## Structural rule
|
||||
|
||||
`internal/base/secrets` owns the rule. Keys are percent-decoded, normalized with
|
||||
NFKC, split at camel-case boundaries and every non-letter/non-digit separator,
|
||||
then Unicode-folded. Comparisons use complete components.
|
||||
|
||||
Sensitive components are token, password, passwd, secret, key, auth,
|
||||
authorization, credential, signature, sig, session, cookie, bearer, jwt,
|
||||
apikey, access and private. Credentials and separated pwd are also recognized;
|
||||
bare PWD and OLDPWD preserve their working-directory meaning.
|
||||
|
||||
- URL userinfo is removed. Query and fragment keys and values are inspected,
|
||||
including values containing nested URLs or query strings. Keyed path
|
||||
credentials and opaque fragments are hidden.
|
||||
- Headers hide the entire value of credential-bearing names, regardless of
|
||||
authentication scheme. Assignments and argument carriers use the same key
|
||||
rule. Header/env carrier flags hide their operands entirely.
|
||||
- Invalid parses, ambiguous commands and depth exhaustion hide the whole
|
||||
affected value. Recursive projection is bounded to 32 levels.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Dependency prose is not a credential grammar. HTTP/SSE bodies, RPC messages,
|
||||
subprocess tails and OAuth rejection descriptions never become diagnostic text.
|
||||
Producers expose typed identity and bounded facts, such as status, RPC code and
|
||||
the number of bytes read or retained.
|
||||
|
||||
`DiagnosticError` preserves `errors.Is` and `errors.As`. Trusted host producers
|
||||
may implement `DiagnosticFacts`; implementations must contain only safe facts.
|
||||
Unknown causes expose their Go type, never their message.
|
||||
|
||||
Subprocess stderr mirrors emit byte-count summaries. Startup stderr carries a
|
||||
count through reconnect wrappers, never a second copy of dependency prose.
|
||||
|
||||
## Guards and scope
|
||||
|
||||
The sink registry checks projection owners and scans MCP consumers for new raw
|
||||
endpoint/header/env formatting, including common local aliases. Inventory
|
||||
consumers use already projected DTOs; operational fingerprints stay operational.
|
||||
|
||||
The deterministic generated corpus covers 512 seeds, structural carriers,
|
||||
separators, encodings, Unicode folding, nesting and IPv6. Boundary tests keep
|
||||
operational credentials separate from display projections.
|
||||
|
||||
Legacy transcript cleanup and extension UI text retain their existing contracts.
|
||||
MCP producers do not use their text scrubbers. Opaque literals under ordinary
|
||||
configuration keys cannot be distinguished from benign literals by this rule.
|
||||
@@ -228,6 +228,18 @@ Expand a plugin row to manage it:
|
||||
- **Export** packages the plugin for sharing, with credentials stripped.
|
||||
- **Remove** uninstalls the package after confirmation.
|
||||
|
||||
MCP displays mask userinfo, credential query/fragment values, credential path
|
||||
suffixes, and opaque fragments. Credential-bearing or uncertain shell text is
|
||||
hidden in display; operational install values remain intact. Parse errors redact
|
||||
input values. Approval tickets bind operational credentials before projection.
|
||||
|
||||
| Surface | Values |
|
||||
| --- | --- |
|
||||
| Operator MCP parse/install | `url`, `env`, `headers`, `command`, `args`: operational values for the install round-trip. |
|
||||
| MCP confirmation UI | Render only `displayUrl`, `displayEnv`, `displayHeaders`, `displayCommand`, `displayArgs`. |
|
||||
| Public install plans and package inventories | Display values; operational entries stay internal. |
|
||||
| Export | Mask URL credentials and credential flag arguments; replace placeholders before connecting. Env/header literals remain environment references. |
|
||||
|
||||
### Use Installed Plugins From Desktop
|
||||
|
||||
The desktop settings page uses the same runtime model as the CLI:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type diagnosticError struct{ cause error }
|
||||
|
||||
func (e diagnosticError) Error() string {
|
||||
var safe interface{ DiagnosticFacts() string }
|
||||
if errors.As(e.cause, &safe) {
|
||||
return safe.DiagnosticFacts()
|
||||
}
|
||||
var endpoint *url.Error
|
||||
if errors.As(e.cause, &endpoint) {
|
||||
return fmt.Sprintf("request failed: %s: %s", RedactEndpoint(endpoint.URL), DiagnosticError(endpoint.Err))
|
||||
}
|
||||
for _, known := range []error{context.Canceled, context.DeadlineExceeded, io.EOF, io.ErrUnexpectedEOF} {
|
||||
if errors.Is(e.cause, known) {
|
||||
return known.Error()
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("dependency failure (%T)", e.cause)
|
||||
}
|
||||
|
||||
func (e diagnosticError) Unwrap() error { return e.cause }
|
||||
|
||||
// DiagnosticError keeps identities while omitting untrusted error prose.
|
||||
func DiagnosticError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return diagnosticError{cause: err}
|
||||
}
|
||||
|
||||
// OmittedText exposes only the byte count of unstructured dependency output.
|
||||
func OmittedText(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("dependency output omitted (%d bytes)", len(text))
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"reasonix/internal/base/shellparse"
|
||||
)
|
||||
|
||||
const EndpointRedacted = "<redacted>"
|
||||
const projectionDepth = 32
|
||||
|
||||
func CredentialKey(key string) bool {
|
||||
for depth := 0; ; depth++ {
|
||||
if depth >= projectionDepth {
|
||||
return true
|
||||
}
|
||||
decoded, err := url.QueryUnescape(key)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if decoded == key {
|
||||
break
|
||||
}
|
||||
key = decoded
|
||||
}
|
||||
runes := []rune(norm.NFKC.String(key))
|
||||
var normalized strings.Builder
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) && i > 0 && (unicode.IsLower(runes[i-1]) || (i+1 < len(runes) && unicode.IsLower(runes[i+1]))) {
|
||||
normalized.WriteByte(' ')
|
||||
}
|
||||
normalized.WriteRune(r)
|
||||
}
|
||||
folded := cases.Fold().String(normalized.String())
|
||||
if folded == "pwd" || folded == "oldpwd" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(folded, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
|
||||
switch part {
|
||||
case "token", "password", "passwd", "pwd", "secret", "key", "auth", "authorization", "credential", "credentials", "signature", "sig", "session", "cookie", "bearer", "jwt", "apikey", "access", "private":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func RedactEndpoint(raw string) string { return projectEndpoint(raw, 0) }
|
||||
|
||||
func projectEndpoint(raw string, depth int) string {
|
||||
if strings.TrimSpace(raw) == "" || raw == EndpointRedacted {
|
||||
return raw
|
||||
}
|
||||
if depth >= projectionDepth {
|
||||
return EndpointRedacted
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" || u.Scheme == "" || u.Opaque != "" {
|
||||
return EndpointRedacted
|
||||
}
|
||||
changed := u.User != nil
|
||||
u.User = nil
|
||||
segments := strings.Split(u.Path, "/")
|
||||
for i, segment := range segments {
|
||||
if CredentialKey(segment) && i+1 < len(segments) {
|
||||
u.Path = strings.Join(segments[:i+1], "/") + "/" + EndpointRedacted
|
||||
u.RawPath = ""
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.Contains(u.RawQuery, ";") {
|
||||
return u.Scheme + "://" + u.Host + "/" + EndpointRedacted
|
||||
}
|
||||
query, ok := projectQuery(u.RawQuery, depth+1)
|
||||
if !ok {
|
||||
return EndpointRedacted
|
||||
}
|
||||
if query != u.RawQuery {
|
||||
u.RawQuery = query
|
||||
changed = true
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
fragment := EndpointRedacted
|
||||
if strings.Contains(u.Fragment, "=") {
|
||||
fragment, ok = projectQuery(u.Fragment, depth+1)
|
||||
if !ok {
|
||||
return EndpointRedacted
|
||||
}
|
||||
} else if strings.Contains(u.Fragment, "://") {
|
||||
fragment = projectEndpoint(u.Fragment, depth+1)
|
||||
}
|
||||
if fragment != u.Fragment {
|
||||
u.Fragment = fragment
|
||||
u.RawFragment = ""
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func projectQuery(raw string, depth int) (string, bool) {
|
||||
if raw == "" {
|
||||
return raw, true
|
||||
}
|
||||
if depth >= projectionDepth {
|
||||
return EndpointRedacted, true
|
||||
}
|
||||
query, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
changed := false
|
||||
out := make(url.Values, len(query))
|
||||
for key, values := range query {
|
||||
projectedKey := projectValue(key, depth+1)
|
||||
for _, value := range values {
|
||||
projected := EndpointRedacted
|
||||
if !CredentialKey(key) {
|
||||
projected = projectValue(value, depth+1)
|
||||
}
|
||||
if value == "" {
|
||||
projected = ""
|
||||
}
|
||||
changed = changed || projected != value || projectedKey != key
|
||||
out[projectedKey] = append(out[projectedKey], projected)
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw, true
|
||||
}
|
||||
return out.Encode(), true
|
||||
}
|
||||
|
||||
func CredentialValue(value string) bool { return RedactConfigValue("", value) != value }
|
||||
|
||||
func RedactConfigValue(key, value string) string {
|
||||
if CredentialKey(key) {
|
||||
return EndpointRedacted
|
||||
}
|
||||
return projectValue(value, 0)
|
||||
}
|
||||
|
||||
func projectValue(value string, depth int) string {
|
||||
if value == "" || value == EndpointRedacted {
|
||||
return value
|
||||
}
|
||||
if depth >= projectionDepth {
|
||||
return EndpointRedacted
|
||||
}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if strings.Contains(trimmed, "://") && !strings.ContainsFunc(trimmed, unicode.IsSpace) && strings.Index(trimmed, "://") < strings.Index(trimmed+"=", "=") {
|
||||
return projectEndpoint(value, depth+1)
|
||||
}
|
||||
if key, nested, ok := strings.Cut(trimmed, ":"); ok {
|
||||
if CredentialKey(key) || projectValue(nested, depth+1) != nested {
|
||||
return EndpointRedacted
|
||||
}
|
||||
}
|
||||
if key, _, ok := strings.Cut(trimmed, "="); ok && CredentialKey(key) && !strings.Contains(key, "://") {
|
||||
return EndpointRedacted
|
||||
}
|
||||
if strings.Contains(trimmed, "=") && !strings.ContainsFunc(trimmed, unicode.IsSpace) {
|
||||
projected, ok := projectQuery(trimmed, depth+1)
|
||||
if !ok {
|
||||
return EndpointRedacted
|
||||
}
|
||||
if projected != trimmed {
|
||||
return projected
|
||||
}
|
||||
}
|
||||
if decoded, err := url.QueryUnescape(value); err != nil {
|
||||
return EndpointRedacted
|
||||
} else if decoded != value && strings.Contains(value, "%") {
|
||||
projected := projectValue(decoded, depth+1)
|
||||
if projected != decoded {
|
||||
return projected
|
||||
}
|
||||
}
|
||||
if strings.ContainsFunc(trimmed, unicode.IsSpace) {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, trimmed)
|
||||
command, err := shellparse.ParseStaticCommand(normalized, shellparse.StaticCommandPolicy{})
|
||||
if err != nil {
|
||||
return EndpointRedacted
|
||||
}
|
||||
if len(command.Argv) > 1 {
|
||||
projected := projectArgs(command.Argv, depth+1)
|
||||
for i := range projected {
|
||||
if projected[i] != command.Argv[i] {
|
||||
return EndpointRedacted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func RedactConfigMap(fields map[string]string) map[string]string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(fields))
|
||||
for key, value := range fields {
|
||||
out[key] = RedactConfigValue(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func RedactArgs(args []string) []string { return projectArgs(args, 0) }
|
||||
|
||||
func projectArgs(args []string, depth int) []string {
|
||||
out := append([]string(nil), args...)
|
||||
for i := 0; i < len(out); i++ {
|
||||
if depth >= projectionDepth {
|
||||
out[i] = EndpointRedacted
|
||||
continue
|
||||
}
|
||||
arg := out[i]
|
||||
if len(arg) > 2 && (strings.HasPrefix(arg, "-H") || strings.HasPrefix(arg, "-e")) {
|
||||
out[i] = arg[:2] + EndpointRedacted
|
||||
continue
|
||||
}
|
||||
key, _, inline := strings.Cut(arg, "=")
|
||||
carrier := key == "-H" || key == "--header" || key == "--headers" || key == "--env" || key == "-e"
|
||||
if header, _, ok := strings.Cut(arg, ":"); ok && CredentialKey(header) && strings.HasSuffix(arg, ":") {
|
||||
for j := i; j < len(out); j++ {
|
||||
out[j] = EndpointRedacted
|
||||
}
|
||||
break
|
||||
}
|
||||
flag := strings.HasPrefix(key, "-") && CredentialKey(strings.TrimLeft(key, "-"))
|
||||
scheme := strings.EqualFold(key, "Basic") || strings.EqualFold(key, "Bearer")
|
||||
if carrier || flag || scheme {
|
||||
if strings.ContainsFunc(arg, unicode.IsSpace) {
|
||||
out[i] = EndpointRedacted
|
||||
}
|
||||
if inline {
|
||||
out[i] = key + "=" + EndpointRedacted
|
||||
} else if i+1 < len(out) {
|
||||
i++
|
||||
out[i] = EndpointRedacted
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[i] = projectValue(arg, depth+1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRereviewAttachedShortCredentialCarriers(t *testing.T) {
|
||||
for _, arg := range []string{"-eKEY=fixturesecret", "-HAuthorization:Basic fixturesecret", "-HAuthorization:Bearer fixturesecret", "-e=KEY=fixturesecret", "-H=Authorization:Basic fixturesecret", "-HX-Custom:fixturesecret"} {
|
||||
t.Run(arg[:2]+strings.ReplaceAll(arg[2:], " ", "_"), func(t *testing.T) {
|
||||
input := []string{arg, "ordinary", "https://host/clean"}
|
||||
original := append([]string(nil), input...)
|
||||
got := RedactArgs(input)
|
||||
if strings.Contains(got[0], "fixturesecret") || got[1] != input[1] || got[2] != input[2] {
|
||||
t.Fatalf("argument projection = %v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(input, original) {
|
||||
t.Fatal("operational argv changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
input := []string{"-v", "--help", "ordinary=value", "https://host/clean", "-e", "KEY=fixturesecret", "tail"}
|
||||
got := RedactArgs(input)
|
||||
if !reflect.DeepEqual(got[:4], input[:4]) || got[5] != EndpointRedacted || got[6] != "tail" {
|
||||
t.Fatalf("neighbouring arguments = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRereviewEndpointPrefixMixedCredentials(t *testing.T) {
|
||||
for _, suffix := range []string{" Authorization: Bearer fixturesecret", " Authorization: Basic fixturesecret", " --header 'X-Custom: fixturesecret'", " -eKEY=fixturesecret", "\tTOKEN=fixturesecret", "\nAuthorization: Basic fixturesecret"} {
|
||||
value := "https://host/mcp" + suffix
|
||||
if got := RedactConfigValue("ORDINARY", value); strings.Contains(got, "fixturesecret") {
|
||||
t.Errorf("mixed value leaked: %s", got)
|
||||
}
|
||||
if got := RedactArgs([]string{value}); strings.Contains(got[0], "fixturesecret") {
|
||||
t.Errorf("mixed argument leaked: %v", got)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"https://host/mcp", "https://host/mcp?author=neutral", "https://host/a%20b", "https://host/mcp ordinary", "ordinary value"} {
|
||||
if got := RedactConfigValue("ORDINARY", value); got != value {
|
||||
t.Errorf("harmless value changed: %q -> %q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRereviewURLArgumentQueryProjection(t *testing.T) {
|
||||
for _, value := range []string{"https://host/mcp?token=fixturesecret", "https://host/mcp?%74oken=fixturesecret", "https://host/mcp#token=fixturesecret"} {
|
||||
if got := RedactArgs([]string{value}); strings.Contains(got[0], "fixturesecret") {
|
||||
t.Errorf("URL argument leaked: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionEndpointPathAndOpaqueFragment(t *testing.T) {
|
||||
for _, raw := range []string{"https://host/token/fixturesecret", "https://host/api_key/fixturesecret", "https://host/%74oken/fixturesecret", "https://host/mcp#fixturesecret"} {
|
||||
if got := RedactEndpoint(raw); strings.Contains(got, "fixturesecret") {
|
||||
t.Errorf("endpoint leaked: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionEmbeddedURLUserinfoSuffix(t *testing.T) {
|
||||
for _, quote := range []string{"'", "\""} {
|
||||
got := RedactConfigValue("ordinary", "prefix https://neutral:head"+quote+"fixturesecret@host/mcp")
|
||||
if strings.Contains(got, "fixturesecret") {
|
||||
t.Errorf("embedded userinfo leaked: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionCredentialCarriers(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"-e", "KEY=fixturesecret"},
|
||||
{"Authorization: Bearer fixturesecret"},
|
||||
{"Authorization: Basic fixturesecret"},
|
||||
{"--header 'Authorization: Bearer fixturesecret'"},
|
||||
{"--env 'KEY=fixturesecret'"},
|
||||
{"--header 'X-Custom: fixturesecret'"},
|
||||
{"node --header 'Authorization: Bearer fixturesecret' && true"},
|
||||
{"node --env 'KEY=fixturesecret"},
|
||||
} {
|
||||
if got := strings.Join(RedactArgs(args), " "); strings.Contains(got, "fixturesecret") {
|
||||
t.Errorf("argument leaked: %s", got)
|
||||
}
|
||||
}
|
||||
if got := RedactConfigValue("ordinary", "Authorization: Basic fixturesecret"); strings.Contains(got, "fixturesecret") {
|
||||
t.Errorf("value leaked: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEndpointConfigProjectionDoesNotMutateOperationalValues(t *testing.T) {
|
||||
fields := map[string]string{"PASSWORD": "fixture-secret", "author": "neutral", "ENDPOINT": "https://host/mcp?%74oken=fixture-secret"}
|
||||
want := map[string]string{"PASSWORD": EndpointRedacted, "author": "neutral", "ENDPOINT": "https://host/mcp?token=%3Credacted%3E"}
|
||||
if got := RedactConfigMap(fields); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("display = %#v", got)
|
||||
}
|
||||
if fields["PASSWORD"] != "fixture-secret" {
|
||||
t.Fatal("operational map mutated")
|
||||
}
|
||||
if RedactConfigMap(nil) != nil || RedactArgs(nil) != nil {
|
||||
t.Fatal("nil projection changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointArgumentsMaskCredentialCarrierFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"https://user:fixture-secret'private@host/mcp"},
|
||||
{"--endpoint=https://user:fixture-secret'private@host/mcp"},
|
||||
{"-H", "Authorization: Basic fixture-secret"},
|
||||
{"--header", "X-Custom: fixture-secret"},
|
||||
{"--env", "CUSTOM=fixture-secret"},
|
||||
{"--headers={\"X-Custom\":\"fixture-secret\"}"},
|
||||
} {
|
||||
if got := strings.Join(RedactArgs(args), " "); strings.Contains(got, "fixture-secret") || strings.Contains(got, "private") {
|
||||
t.Fatalf("flag leaked: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointDiagnosticErrorKeepsIdentityAndMasksEntireURL(t *testing.T) {
|
||||
cause := errors.New("fixture unavailable")
|
||||
wrapped := &url.Error{Op: "Post", URL: "https://user:fixture secret@host/mcp%zz", Err: cause}
|
||||
err := DiagnosticError(wrapped)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatal("error identity lost")
|
||||
}
|
||||
var endpoint *url.Error
|
||||
if !errors.As(err, &endpoint) || endpoint != wrapped {
|
||||
t.Fatal("typed cause lost")
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixture secret") || strings.Contains(err.Error(), "secret@") {
|
||||
t.Fatalf("diagnostic leaked: %s", err)
|
||||
}
|
||||
if DiagnosticError(nil) != nil {
|
||||
t.Fatal("nil error changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactEndpointSemicolonQueryKeepsOrigin(t *testing.T) {
|
||||
got := RedactEndpoint("https://mcp.example.com/sse?a=1;token=abc")
|
||||
if got != "https://mcp.example.com/"+EndpointRedacted {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// secretKeyNamePattern matches environment-variable / key names that are
|
||||
// likely to carry credentials. Bare "pwd" is intentionally excluded: it
|
||||
// only counts with a leading separator (DB_PWD, MYSQL-PWD), so the POSIX
|
||||
// PWD / OLDPWD working-directory variables never match.
|
||||
secretKeyNamePattern = regexp.MustCompile(`(?i)((^|[_-])(api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|passwd)([_-]|$)|[_-]pwd([_-]|$))`)
|
||||
endpointPattern = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^\s<>]+`)
|
||||
// cookieHeaderPattern captures Cookie/Set-Cookie header values so every
|
||||
// name=value pair gets its value masked; attribute flags without a value
|
||||
// (HttpOnly, Secure) pass through untouched.
|
||||
@@ -109,7 +105,7 @@ func EnvKeySensitive(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return secretKeyNamePattern.MatchString(key)
|
||||
return CredentialKey(key)
|
||||
}
|
||||
|
||||
// FilterEnv removes sensitive KEY=value assignments from an environment vector.
|
||||
@@ -186,6 +182,10 @@ func RedactCredentials(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = endpointPattern.ReplaceAllStringFunc(s, func(endpoint string) string {
|
||||
trimmed := strings.TrimRight(endpoint, ":,.)")
|
||||
return RedactEndpoint(trimmed) + endpoint[len(trimmed):]
|
||||
})
|
||||
s = Redact(s)
|
||||
s = credentialContextPattern.ReplaceAllString(s, "${1}${2}****")
|
||||
s = maskedCredentialPattern.ReplaceAllString(s, "****")
|
||||
@@ -266,7 +266,7 @@ func redactKeyValues(s string) string {
|
||||
value := s[valueStart:valueEnd]
|
||||
if authorizationKey(key) {
|
||||
out.WriteString(redactedValue)
|
||||
} else if value == "****" || value == redactedValue {
|
||||
} else if value == redactedValue || strings.Contains(value, "**") {
|
||||
out.WriteString(value)
|
||||
} else {
|
||||
out.WriteString(mask(value))
|
||||
@@ -295,18 +295,11 @@ func authorizationKey(key string) bool {
|
||||
}
|
||||
|
||||
func credentialTextKeySensitive(key string) bool {
|
||||
upper := strings.ToUpper(key)
|
||||
compact := strings.NewReplacer("_", "", "-", "").Replace(upper)
|
||||
return authorizationKey(key) ||
|
||||
strings.Contains(compact, "APIKEY") ||
|
||||
strings.Contains(compact, "ACCESSKEY") ||
|
||||
strings.Contains(compact, "PRIVATEKEY") ||
|
||||
strings.Contains(upper, "SECRET") ||
|
||||
strings.Contains(upper, "TOKEN") ||
|
||||
strings.Contains(upper, "PASSWORD") ||
|
||||
strings.Contains(upper, "PASSWD") ||
|
||||
strings.Contains(upper, "_PWD") ||
|
||||
strings.Contains(upper, "-PWD")
|
||||
switch strings.ToLower(key) {
|
||||
case "cookie", "set-cookie":
|
||||
return false
|
||||
}
|
||||
return CredentialKey(key)
|
||||
}
|
||||
|
||||
func authorizationScheme(s string) bool {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSinkGuardRecognizesNewFormattingSinks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
expression string
|
||||
unsafe bool
|
||||
}{
|
||||
{`entry.URL`, true},
|
||||
{`strings.TrimSpace(entry.URL)`, true},
|
||||
{`entry.Headers["Authorization"]`, true},
|
||||
{`entry.Env["TOKEN"]`, true},
|
||||
{`secrets.RedactEndpoint(entry.URL)`, false},
|
||||
{`secrets.RedactConfigValue(key, entry.Env[key])`, false},
|
||||
{`secrets.RedactConfigMap(entry.Headers)`, false},
|
||||
{`entry.Name`, false},
|
||||
} {
|
||||
expression, err := parser.ParseExpr(tc.expression)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rawCredentialField(expression); got != tc.unsafe {
|
||||
t.Errorf("sink %s unsafe=%t, want %t", tc.expression, got, tc.unsafe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinkGuardTracksLocalAliases(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "neutral.go", `package neutral
|
||||
func display(entry Entry) {
|
||||
value := entry.URL
|
||||
alias := strings.TrimSpace(value)
|
||||
fmt.Printf("%s", alias)
|
||||
for _, header := range entry.Headers { fmt.Printf("%s", header) }
|
||||
}`, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := file.Decls[0].(*ast.FuncDecl).Body
|
||||
sources := sinkSources(body)
|
||||
for _, name := range []string{"value", "alias", "header"} {
|
||||
if !credentialSource(ast.NewIdent(name), sources) {
|
||||
t.Errorf("unprojected alias escaped: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCredentialSinkRegistry(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..")
|
||||
registry := map[string][]string{
|
||||
"internal/ext/mcpsetup/secrets.go:Redact": {"RedactConfigValue"},
|
||||
"internal/ext/mcpsetup/secrets.go:RedactURL": {"RedactEndpoint"},
|
||||
"internal/ext/installsource/types.go:publicActions": {"RedactEndpoint", "RedactConfigMap", "RedactArgs"},
|
||||
"internal/ext/installsource/mcp.go:mcpActionRisk": {"RedactEndpoint", "CredentialKey", "CredentialValue"},
|
||||
"internal/ext/installsource/marketplace_object_source.go:marketplaceObjectSource": {"RedactEndpoint"},
|
||||
"internal/ext/pluginpkg/export.go:stripNode": {"RedactEndpoint", "RedactArgs", "RedactConfigValue"},
|
||||
"internal/ext/pluginpkg/inventory_mcp.go:mcpServerRefs": {"RedactEndpoint", "RedactConfigValue"},
|
||||
"internal/frontend/cli/mcp.go:mcpList": {"redactMCPURL", "RedactArgs"},
|
||||
"internal/frontend/cli/mcp.go:printMCPEntry": {"redactMCPURL", "redactMCPConfigValue", "RedactArgs"},
|
||||
"internal/frontend/serve/catalog.go:mcpParse": {"RedactURL", "RedactConfigMap", "RedactArgs"},
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
projectedSinks := map[string]bool{"internal/frontend/cli/plugin.go:printPluginInventory": true}
|
||||
operationalFormatters := map[string]bool{"internal/frontend/serve/provider_edit.go:assemblyShape": true}
|
||||
for _, dir := range []string{"internal/ext/mcpsetup", "internal/ext/installsource", "internal/ext/pluginpkg", "internal/frontend/cli", "internal/frontend/serve", "internal/ext/plugin"} {
|
||||
err := filepath.WalkDir(filepath.Join(root, filepath.FromSlash(dir)), func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, declaration := range file.Decls {
|
||||
function, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok || function.Body == nil {
|
||||
continue
|
||||
}
|
||||
id := filepath.ToSlash(rel) + ":" + function.Name.Name
|
||||
sources := sinkSources(function.Body)
|
||||
calls := map[string]bool{}
|
||||
ast.Inspect(function.Body, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := sinkCallName(call.Fun)
|
||||
calls[name] = true
|
||||
if selector, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||
if owner, ok := selector.X.(*ast.Ident); ok && owner.Name == "secrets" && (name == "RedactCredentials" || name == "RedactError") && (strings.HasPrefix(dir, "internal/ext/plugin") || dir == "internal/ext/installsource") {
|
||||
t.Errorf("MCP diagnostic sink %s uses a text scrubber", id)
|
||||
}
|
||||
}
|
||||
if !projectedSinks[id] && !operationalFormatters[id] && (name == "Printf" || name == "Sprintf" || name == "Errorf" || name == "Warn" || name == "Info") {
|
||||
for _, arg := range call.Args {
|
||||
if credentialSource(arg, sources) {
|
||||
t.Errorf("unprojected credential sink %s: %s", id, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if required, ok := registry[id]; ok {
|
||||
seen[id] = true
|
||||
for _, call := range required {
|
||||
if !calls[call] {
|
||||
t.Errorf("sink %s bypasses %s", id, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for id := range registry {
|
||||
if !seen[id] {
|
||||
t.Errorf("sink missing from registry scan: %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sinkCallName(expr ast.Expr) string {
|
||||
switch expr := expr.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
return expr.Sel.Name
|
||||
case *ast.Ident:
|
||||
return expr.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rawCredentialField(expr ast.Expr) bool {
|
||||
return credentialSource(expr, nil)
|
||||
}
|
||||
|
||||
func credentialSource(expr ast.Expr, sources map[string]bool) bool {
|
||||
found := false
|
||||
ast.Inspect(expr, func(node ast.Node) bool {
|
||||
if call, ok := node.(*ast.CallExpr); ok {
|
||||
switch sinkCallName(call.Fun) {
|
||||
case "RedactURL", "RedactEndpoint", "RedactConfigValue", "RedactConfigMap", "RedactArgs", "redactMCPURL", "redactMCPConfigValue", "Redact", "DiagnosticError", "len", "cap", "sortedMapKeys":
|
||||
return false
|
||||
}
|
||||
}
|
||||
if selector, ok := node.(*ast.SelectorExpr); ok {
|
||||
if selector.Sel.Name == "Path" {
|
||||
return false
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "URL", "Headers", "Env", "getURL", "url":
|
||||
found = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if composite, ok := node.(*ast.CompositeLit); ok {
|
||||
for _, element := range composite.Elts {
|
||||
found = found || credentialSource(element, sources)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if identifier, ok := node.(*ast.Ident); ok && sources[identifier.Name] {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func sinkSources(body *ast.BlockStmt) map[string]bool {
|
||||
sources := map[string]bool{}
|
||||
for range 3 {
|
||||
ast.Inspect(body, func(node ast.Node) bool {
|
||||
switch statement := node.(type) {
|
||||
case *ast.AssignStmt:
|
||||
for i, value := range statement.Rhs {
|
||||
if call, ok := value.(*ast.CallExpr); ok && !sourceTransform(call) {
|
||||
continue
|
||||
}
|
||||
if i < len(statement.Lhs) && credentialSource(value, sources) {
|
||||
if name, ok := statement.Lhs[i].(*ast.Ident); ok {
|
||||
sources[name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.RangeStmt:
|
||||
if credentialSource(statement.X, sources) {
|
||||
if name, ok := statement.Value.(*ast.Ident); ok {
|
||||
sources[name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func sourceTransform(call *ast.CallExpr) bool {
|
||||
switch sinkCallName(call.Fun) {
|
||||
case "TrimSpace", "Join", "String", "Sprintf", "ToLower", "ToUpper", "ReplaceAll":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRound3MixedCredentialContext(t *testing.T) {
|
||||
for _, suffix := range []string{" Bearer neutralprobe", " Basic neutralprobe", "\rTOKEN=neutralprobe", "\fTOKEN=neutralprobe", "\tclientToken=neutralprobe"} {
|
||||
input := "https://host/mcp" + suffix
|
||||
for _, got := range []string{RedactConfigValue("ordinary", input), strings.Join(RedactArgs([]string{input}), " ")} {
|
||||
if strings.Contains(got, "neutralprobe") {
|
||||
t.Errorf("credential survived: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRound3DottedComponents(t *testing.T) {
|
||||
for _, key := range []string{"client.token", "client.password", "client.secret", "clientToken", "clientJWT", "CLIENT.ſecret"} {
|
||||
if !CredentialKey(key) {
|
||||
t.Errorf("credential component missed: %s", key)
|
||||
}
|
||||
for _, carrier := range []string{"?", "#"} {
|
||||
got := RedactEndpoint("https://host/mcp" + carrier + url.QueryEscape(key) + "=neutralprobe")
|
||||
if strings.Contains(got, "neutralprobe") {
|
||||
t.Errorf("credential survived: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRound3NestedValues(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"https://host/mcp?next=" + url.QueryEscape("https://u:neutralprobe@host/mcp"),
|
||||
"https://host/mcp#redirect=https://u:neutralprobe@host/mcp",
|
||||
"https://host/mcp?next=" + url.QueryEscape("client.token=neutralprobe"),
|
||||
} {
|
||||
if got := RedactEndpoint(input); strings.Contains(got, "neutralprobe") {
|
||||
t.Errorf("nested credential survived: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuralRepeatedKeyEncoding(t *testing.T) {
|
||||
key := "client.token"
|
||||
for range 4 {
|
||||
key = strings.ReplaceAll(url.QueryEscape(key), ".", "%2e")
|
||||
if !CredentialKey(key) {
|
||||
t.Errorf("encoded key missed: %s", key)
|
||||
}
|
||||
input := "https://host/?" + url.QueryEscape(key) + "=neutralprobe"
|
||||
if got := RedactEndpoint(input); strings.Contains(got, "neutralprobe") {
|
||||
t.Errorf("encoded credential survived: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuralGeneratedCorpus(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(11785, 11816))
|
||||
components := []string{"token", "password", "passwd", "secret", "key", "auth", "authorization", "credential", "signature", "sig", "session", "cookie", "bearer", "jwt", "apikey", "access", "private"}
|
||||
separators := []string{".", "-", "_", "/", ":", "\t", "\r", "\f"}
|
||||
for seed := range 512 {
|
||||
canary := fmt.Sprintf("neutralprobe%08d", seed)
|
||||
part := components[rng.IntN(len(components))]
|
||||
key := "client" + separators[rng.IntN(len(separators))] + strings.ToUpper(part)
|
||||
nested := "https://u:" + canary + "@[::1]:8443/mcp?" + url.QueryEscape(key) + "=" + canary
|
||||
inputs := []string{"https://host/?next=" + url.QueryEscape(nested), "https://host/#redirect=" + url.QueryEscape(nested), key + "=" + canary, key + ": token " + canary}
|
||||
var encoded strings.Builder
|
||||
for _, b := range []byte(canary) {
|
||||
fmt.Fprintf(&encoded, "%%%02x", b)
|
||||
}
|
||||
inputs = append(inputs, "https://host/?"+url.QueryEscape(key)+"="+encoded.String(), "https://host/?next="+url.QueryEscape("https://host/?clientToken="+encoded.String()), "client.ſecret="+encoded.String())
|
||||
for _, input := range inputs {
|
||||
outputs := []string{RedactConfigValue("ordinary", input), strings.Join(RedactArgs([]string{input}), " "), RedactConfigMap(map[string]string{key: canary})[key]}
|
||||
for _, got := range outputs {
|
||||
decoded := got
|
||||
for range projectionDepth {
|
||||
if strings.Contains(decoded, canary) {
|
||||
t.Fatalf("seed %d input %q output %q", seed, input, got)
|
||||
}
|
||||
next, err := url.QueryUnescape(decoded)
|
||||
if err != nil || next == decoded {
|
||||
break
|
||||
}
|
||||
decoded = next
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--client" + separators[rng.IntN(len(separators))] + part, canary}, {"-e", key + "=" + canary}, {"-H", key + ": Digest " + canary}, {"https://host/mcp", "Basic", canary}} {
|
||||
if got := strings.Join(RedactArgs(args), " "); strings.Contains(got, canary) {
|
||||
t.Fatalf("seed %d args %q output %q", seed, args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,9 @@ func applyConfigItem(cfg *config.Config, it Item) error {
|
||||
return fmt.Errorf("%w: provider %q", ErrMalformed, it.Name)
|
||||
}
|
||||
for _, have := range cfg.Providers {
|
||||
if have.Name == entry.Name && endpoints(have) == endpoints(entry) {
|
||||
if have.Name == entry.Name && endpoints(secretsRedacted(have)) == endpoints(entry) {
|
||||
entry.BaseURL, entry.ChatURL, entry.RequestURL = have.BaseURL, have.ChatURL, have.RequestURL
|
||||
entry.ModelsURL, entry.BalanceURL = have.ModelsURL, have.BalanceURL
|
||||
entry.Headers = keepLocalSecrets(entry.Headers, have.Headers)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +200,8 @@ func applyConfigItem(cfg *config.Config, it Item) error {
|
||||
return fmt.Errorf("%w: MCP server %q", ErrMalformed, it.Name)
|
||||
}
|
||||
for _, have := range cfg.Plugins {
|
||||
if have.Name == entry.Name && have.URL == entry.URL && have.Command == entry.Command {
|
||||
if holdsLocal(entry, have) {
|
||||
entry.URL, entry.Args = have.URL, have.Args
|
||||
entry.Env = keepLocalSecrets(entry.Env, have.Env)
|
||||
entry.Headers = keepLocalSecrets(entry.Headers, have.Headers)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
@@ -150,6 +151,7 @@ func (c *collector) extensions() error {
|
||||
if !c.withSecrets {
|
||||
entry.Env, entry.Headers = stripSecrets(entry.Name, entry.Env, entry.Headers)
|
||||
entry.URL = stripURLSecrets(entry.URL)
|
||||
entry.Args = secrets.RedactArgs(entry.Args)
|
||||
}
|
||||
raw, err := encodeTOML(entry)
|
||||
if err != nil {
|
||||
|
||||
@@ -353,3 +353,51 @@ func TestEndpointsImportsAndReplacedKeysNeedConsent(t *testing.T) {
|
||||
t.Fatalf("the preview must show the instruction file's body: %q", row.Content)
|
||||
}
|
||||
}
|
||||
|
||||
const urlCredentialConfig = `
|
||||
[[plugins]]
|
||||
name = "hosted"
|
||||
type = "http"
|
||||
url = "https://mcp.example.com/sse?token=url-secret-value"
|
||||
|
||||
[[plugins]]
|
||||
name = "local"
|
||||
command = "npx"
|
||||
args = ["-y", "local-server", "--api-key", "arg-secret-value"]
|
||||
`
|
||||
|
||||
func TestExportAndPreviewMaskEndpointAndArgCredentials(t *testing.T) {
|
||||
machine(t, urlCredentialConfig)
|
||||
s := collectAll(t, CategoryExtensions)
|
||||
raw, _ := json.Marshal(s)
|
||||
for _, secret := range []string{"url-secret-value", "arg-secret-value"} {
|
||||
if bytes.Contains(raw, []byte(secret)) {
|
||||
t.Fatalf("snapshot without secrets carries %q", secret)
|
||||
}
|
||||
}
|
||||
p := NewPlanner()
|
||||
plan, err := p.Preview(s)
|
||||
must(t, err)
|
||||
shown, _ := json.Marshal(plan)
|
||||
for _, secret := range []string{"url-secret-value", "arg-secret-value"} {
|
||||
if bytes.Contains(shown, []byte(secret)) {
|
||||
t.Fatalf("preview carries %q", secret)
|
||||
}
|
||||
}
|
||||
var ids []string
|
||||
for _, it := range plan.Items {
|
||||
if it.Kind == KindMCP {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
}
|
||||
_, err = p.Apply(ApplyRequest{PlanID: plan.ID, Items: ids, Consented: ids})
|
||||
must(t, err)
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
must(t, err)
|
||||
got, _ := json.Marshal(cfg.Plugins)
|
||||
for _, secret := range []string{"url-secret-value", "arg-secret-value"} {
|
||||
if !bytes.Contains(got, []byte(secret)) {
|
||||
t.Fatalf("restore over the same machine dropped local %q: %s", secret, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
@@ -18,16 +19,17 @@ func describe(it Item) (string, []PathRef) {
|
||||
case KindProvider:
|
||||
var p config.ProviderEntry
|
||||
if decodeTOML(it.Data, &p) == nil {
|
||||
return strings.TrimSpace(p.Kind + " " + firstNonEmpty(p.RequestURL, p.BaseURL, p.ChatURL)), nil
|
||||
return strings.TrimSpace(p.Kind + " " + secrets.RedactEndpoint(firstNonEmpty(p.RequestURL, p.BaseURL, p.ChatURL))), nil
|
||||
}
|
||||
case KindMCP:
|
||||
var p config.PluginEntry
|
||||
if decodeTOML(it.Data, &p) == nil {
|
||||
if strings.TrimSpace(p.Command) == "" {
|
||||
return p.URL, nil
|
||||
return secrets.RedactEndpoint(p.URL), nil
|
||||
}
|
||||
line := strings.TrimSpace(p.Command + " " + strings.Join(p.Args, " "))
|
||||
return line, absPaths(append([]string{p.Command}, p.Args...)...)
|
||||
args := secrets.RedactArgs(p.Args)
|
||||
line := strings.TrimSpace(p.Command + " " + strings.Join(args, " "))
|
||||
return line, absPaths(append([]string{p.Command}, args...)...)
|
||||
}
|
||||
case KindHook:
|
||||
var h hookData
|
||||
@@ -69,15 +71,15 @@ func details(it Item) ([]string, string) {
|
||||
if decodeTOML(it.Data, &p) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
out := labelled("base_url", p.BaseURL, "request_url", p.RequestURL, "chat_url", p.ChatURL,
|
||||
"models_url", p.ModelsURL, "balance_url", p.BalanceURL, "api_key_env", p.APIKeyEnv)
|
||||
out := labelled("base_url", stripURLSecrets(p.BaseURL), "request_url", stripURLSecrets(p.RequestURL), "chat_url", stripURLSecrets(p.ChatURL),
|
||||
"models_url", stripURLSecrets(p.ModelsURL), "balance_url", stripURLSecrets(p.BalanceURL), "api_key_env", p.APIKeyEnv)
|
||||
return append(out, pairs("header", p.Headers)...), ""
|
||||
case KindMCP:
|
||||
var p config.PluginEntry
|
||||
if decodeTOML(it.Data, &p) != nil {
|
||||
return nil, ""
|
||||
}
|
||||
out := labelled("type", p.Type, "url", p.URL)
|
||||
out := labelled("type", p.Type, "url", secrets.RedactEndpoint(p.URL))
|
||||
return append(append(out, pairs("env", p.Env)...), pairs("header", p.Headers)...), ""
|
||||
case KindHook:
|
||||
var h hookData
|
||||
@@ -111,6 +113,7 @@ func labelled(kv ...string) []string {
|
||||
}
|
||||
|
||||
func pairs(label string, m map[string]string) []string {
|
||||
m = secrets.RedactConfigMap(m)
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
@@ -174,19 +175,32 @@ func isPlaceholder(v string) bool {
|
||||
return strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}")
|
||||
}
|
||||
|
||||
// stripURLSecrets drops userinfo, which is where a token hides in a git or
|
||||
// gateway URL. A query string is left alone: it is more often a project id
|
||||
// than a key, and a URL cut short breaks the restore in a way nobody can see.
|
||||
// stripURLSecrets masks credentials in a URL (userinfo, credential-named
|
||||
// query and path parameters). A value that is not a URL (a scp-style git
|
||||
// source, a bare path) has no endpoint to project and passes through.
|
||||
func stripURLSecrets(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.User == nil || u.Host == "" {
|
||||
if !strings.Contains(raw, "://") {
|
||||
return raw
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
return secrets.RedactEndpoint(raw)
|
||||
}
|
||||
|
||||
// holdsLocal reports whether a restored, redacted MCP entry is the local one
|
||||
// with its credentials masked, so the local URL and args can be kept.
|
||||
func holdsLocal(restored, have config.PluginEntry) bool {
|
||||
return have.Name == restored.Name && have.Command == restored.Command &&
|
||||
(have.URL == restored.URL || secrets.RedactEndpoint(have.URL) == restored.URL) &&
|
||||
(slices.Equal(have.Args, restored.Args) || slices.Equal(secrets.RedactArgs(have.Args), restored.Args))
|
||||
}
|
||||
|
||||
func hookName(event string, h hook.HookConfig) string {
|
||||
sum := sha256.Sum256([]byte(event + "\x00" + h.Match + "\x00" + h.Command))
|
||||
return event + "#" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
func secretsRedacted(p config.ProviderEntry) config.ProviderEntry {
|
||||
for _, u := range []*string{&p.BaseURL, &p.ChatURL, &p.RequestURL, &p.ModelsURL, &p.BalanceURL} {
|
||||
*u = stripURLSecrets(*u)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestMCPPlanKeepsCredentialsOnlyInOperationalEntry(t *testing.T) {
|
||||
raw := "https://user:fixture-secret@host/mcp?%74oken=fixture-secret"
|
||||
task := &Tool{}
|
||||
a := task.mcpEntryAction(request{}, config.PluginEntry{Name: "neutral", URL: raw, Headers: map[string]string{"Authorization": "fixture-secret"}, Env: map[string]string{"PASSWORD": "fixture-secret"}, Args: []string{"--password", "fixture-secret"}}, raw)
|
||||
if a.entry.URL != raw || a.entry.Headers["Authorization"] != "fixture-secret" {
|
||||
t.Fatal("operational entry changed")
|
||||
}
|
||||
if got := marshalJSON(publicActions([]action{a})); strings.Contains(got, "fixture-secret") {
|
||||
t.Fatalf("plan leaked: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestRevisionApprovalBindsOperationalCredentials(t *testing.T) {
|
||||
for _, field := range []string{"url", "headers", "env", "args"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
source := filepath.Join(root, ".mcp.json")
|
||||
connected := false
|
||||
var received config.PluginEntry
|
||||
stop := errors.New("fixture stop before persistence")
|
||||
tl := NewTool(Options{ProjectRoot: root, HomeDir: root, RequireApprovedPlan: true, ConnectMCP: func(entry config.PluginEntry) (MCPConnectResult, error) {
|
||||
connected = true
|
||||
received = entry
|
||||
return MCPConnectResult{}, stop
|
||||
}})
|
||||
write := func(secret string) {
|
||||
entry := map[string]any{"url": "https://host/mcp", "command": ""}
|
||||
switch field {
|
||||
case "url":
|
||||
entry["url"] = "https://host/mcp?token=" + secret
|
||||
case "headers":
|
||||
entry["headers"] = map[string]string{"Authorization": secret}
|
||||
case "env":
|
||||
entry["env"] = map[string]string{"PASSWORD": secret}
|
||||
case "args":
|
||||
delete(entry, "url")
|
||||
entry["command"] = "node"
|
||||
entry["args"] = []string{"--token", secret}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"neutral": entry}})
|
||||
writeFile(t, source, string(b))
|
||||
}
|
||||
write("fixturefirst")
|
||||
preview := execInstall(t, tl, map[string]any{"source": source, "kind": "mcp"})
|
||||
write("fixturesecond")
|
||||
updated := execInstall(t, tl, map[string]any{"source": source, "kind": "mcp"})
|
||||
if preview.PlanID == updated.PlanID {
|
||||
t.Error("credential change kept approval ticket")
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"source": source, "kind": "mcp", "apply": true, "planId": preview.PlanID})
|
||||
_, _ = tl.Execute(t.Context(), raw)
|
||||
if connected {
|
||||
t.Fatal("old ticket connected changed credentials")
|
||||
}
|
||||
raw, _ = json.Marshal(map[string]any{"source": source, "kind": "mcp", "apply": true, "planId": updated.PlanID})
|
||||
output, _ := tl.Execute(t.Context(), raw)
|
||||
if !connected {
|
||||
t.Fatal("current ticket did not connect")
|
||||
}
|
||||
operational, _ := json.Marshal(received)
|
||||
if !strings.Contains(string(operational), "fixturesecond") {
|
||||
t.Fatal("connector credential lost")
|
||||
}
|
||||
if strings.Contains(output, "fixturesecond") {
|
||||
t.Fatal("apply response leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionInstallSourceErrorMasksEndpoint(t *testing.T) {
|
||||
tl := NewTool(Options{ProjectRoot: testenv.TempDir(t), HomeDir: testenv.TempDir(t)})
|
||||
_, err := tl.Execute(t.Context(), json.RawMessage(`{"source":"https://host/mcp?%74oken=fixturesecret","kind":"skill"}`))
|
||||
if !errors.Is(err, ErrUnsupportedKind) {
|
||||
t.Fatalf("kind identity lost: %v", err)
|
||||
}
|
||||
if err == nil || strings.Contains(err.Error(), "fixturesecret") {
|
||||
t.Fatalf("source error leaked: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package installsource
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRound3InstallDiagnosticsOmitUnstructuredText(t *testing.T) {
|
||||
err := newErr(ErrSourceUnreadable, "subprocess: arbitrary neutralprobe")
|
||||
if !errors.Is(err, ErrSourceUnreadable) {
|
||||
t.Fatal("failure identity lost")
|
||||
}
|
||||
for _, got := range []string{err.Error(), marshalJSON(publicActions([]action{{Error: "Basic rxprobe"}})), marshalJSON(response{Error: "Bearer rxprobe"})} {
|
||||
if strings.Contains(got, "neutralprobe") || strings.Contains(got, "rxprobe") {
|
||||
t.Errorf("unstructured diagnostic survived: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,16 +66,72 @@ var (
|
||||
ErrNotPinnable = errors.New("install_source: source cannot be pinned by content")
|
||||
)
|
||||
|
||||
// errKind wraps a sentinel with a human-readable detail so logs and the
|
||||
// `next` field stay useful.
|
||||
type errKind struct {
|
||||
sentinel error
|
||||
detail string
|
||||
sentinel error
|
||||
detailBytes int
|
||||
}
|
||||
|
||||
func (e *errKind) Error() string { return fmt.Sprintf("%s: %s", e.sentinel, e.detail) }
|
||||
func (e *errKind) Unwrap() error { return e.sentinel }
|
||||
func (e *errKind) Error() string {
|
||||
return fmt.Sprintf("%s (detail omitted; %d bytes)", e.sentinel, e.detailBytes)
|
||||
}
|
||||
func (e *errKind) Unwrap() error { return e.sentinel }
|
||||
func (e *errKind) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
func newErr(sentinel error, format string, args ...any) error {
|
||||
return &errKind{sentinel: sentinel, detail: fmt.Sprintf(format, args...)}
|
||||
return &errKind{sentinel: sentinel, detailBytes: len(fmt.Sprintf(format, args...))}
|
||||
}
|
||||
|
||||
// hostFactError carries a sentence the host composed from its own counts; no
|
||||
// peer-supplied text may enter it.
|
||||
type hostFactError struct {
|
||||
sentinel error
|
||||
facts string
|
||||
}
|
||||
|
||||
func (e *hostFactError) Error() string { return fmt.Sprintf("%s: %s", e.sentinel, e.facts) }
|
||||
func (e *hostFactError) Unwrap() error { return e.sentinel }
|
||||
func (e *hostFactError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type sourceHTTPError struct {
|
||||
sentinel error
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *sourceHTTPError) Error() string { return fmt.Sprintf("%s: HTTP %d", e.sentinel, e.status) }
|
||||
func (e *sourceHTTPError) Unwrap() error { return e.sentinel }
|
||||
func (e *sourceHTTPError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type sourceBoundaryCode uint8
|
||||
|
||||
const (
|
||||
sourceEscape sourceBoundaryCode = iota
|
||||
sourceEntrySize
|
||||
sourceTotalSize
|
||||
sourceFileCount
|
||||
)
|
||||
|
||||
type sourceBoundaryError struct {
|
||||
code sourceBoundaryCode
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (e *sourceBoundaryError) Error() string {
|
||||
switch e.code {
|
||||
case sourceEscape:
|
||||
return "tarball entry escapes the permitted root"
|
||||
case sourceEntrySize:
|
||||
return fmt.Sprintf("tarball entry is larger than %d bytes", e.limit)
|
||||
case sourceTotalSize:
|
||||
return fmt.Sprintf("tarball expands past %d bytes", e.limit)
|
||||
case sourceFileCount:
|
||||
return fmt.Sprintf("tarball holds more than %d files", e.limit)
|
||||
}
|
||||
return "tarball boundary refusal"
|
||||
}
|
||||
func (e *sourceBoundaryError) Unwrap() error {
|
||||
if e.code == sourceEscape {
|
||||
return ErrUnsupportedKind
|
||||
}
|
||||
return ErrSourceUnreadable
|
||||
}
|
||||
func (e *sourceBoundaryError) DiagnosticFacts() string { return e.Error() }
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
@@ -166,7 +167,8 @@ func (*Tool) Schema() json.RawMessage {
|
||||
// Execute parses args, plans, and (if apply=true and Approval allows)
|
||||
// performs the writes. JSON output is always returned on success even when
|
||||
// the plan is empty, so the model can read structured `next` hints.
|
||||
func (t *Tool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
func (t *Tool) Execute(ctx context.Context, raw json.RawMessage) (output string, err error) {
|
||||
defer func() { err = secrets.DiagnosticError(err) }()
|
||||
var req request
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
return "", fmt.Errorf("install_source: invalid args: %w", err)
|
||||
@@ -202,15 +204,14 @@ func (t *Tool) Execute(ctx context.Context, raw json.RawMessage) (string, error)
|
||||
return marshalJSON(response{
|
||||
OK: false, Status: "blocked", Op: req.Op, Applied: false,
|
||||
Source: req.Source, Kind: "plugin", Scope: req.Scope, Mode: req.Mode,
|
||||
Warnings: warnings, Error: err.Error(),
|
||||
Warnings: warnings, failure: err,
|
||||
Next: "Choose a plugin that exports a supported skill, command, agent, hook, or MCP server.",
|
||||
}), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Marketplace planning may keep one temporary clone alive so apply can
|
||||
// reuse the exact approved snapshot. Clean it on every exit path, including
|
||||
// plan-ID mismatch or host approval denial before executeApply runs.
|
||||
// Marketplace apply reuses the approved snapshot; release its temporary clone
|
||||
// even when approval or plan-ID validation refuses the operation.
|
||||
defer cleanupActionResources(actions)
|
||||
if err := checkExpectedDigest(req.ExpectDigest, actions); err != nil {
|
||||
return "", err
|
||||
@@ -298,7 +299,7 @@ func (t *Tool) executeApply(ctx context.Context, req request, actions []action,
|
||||
if err := t.apply(ctx, req, &actions[i]); err != nil {
|
||||
ok = false
|
||||
actions[i].Status = "failed"
|
||||
actions[i].Error = err.Error()
|
||||
actions[i].failure = err
|
||||
if actions[i].Next == "" {
|
||||
actions[i].Next = nextForError(err)
|
||||
}
|
||||
@@ -387,7 +388,7 @@ func (t *Tool) executeUninstall(req request) string {
|
||||
if err := t.apply(context.Background(), req, &actions[i]); err != nil {
|
||||
ok = false
|
||||
actions[i].Status = "failed"
|
||||
actions[i].Error = err.Error()
|
||||
actions[i].failure = err
|
||||
actions[i].Next = "Inspect the error, then retry op=uninstall."
|
||||
continue
|
||||
}
|
||||
@@ -617,13 +618,9 @@ func (t *Tool) resolvePath(p string) string {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
|
||||
// computePlanID hashes the request plus the full public action set so a later
|
||||
// apply call with the same planId can be verified to be approving exactly the
|
||||
// same plan. It intentionally excludes Apply and PlanID; everything that changes
|
||||
// what will be written/connected must live either in req's planning fields or in
|
||||
// the action DTO.
|
||||
// Approval binds operational material; display projection must never collapse distinct credentials.
|
||||
func computePlanID(req request, actions []action) string {
|
||||
public := publicActions(actions)
|
||||
public := append([]action(nil), actions...)
|
||||
sort.Slice(public, func(i, j int) bool {
|
||||
return actionPlanKey(public[i]) < actionPlanKey(public[j])
|
||||
})
|
||||
|
||||
@@ -595,8 +595,8 @@ func TestParseSkillContentRejectsMalformedFrontmatter(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("malformed frontmatter should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid YAML") || !strings.Contains(strings.ToLower(err.Error()), "line") {
|
||||
t.Fatalf("error = %v, want invalid YAML with location", err)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("error = %v, want invalid manifest identity", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2062,7 +2062,7 @@ func TestGitHubClaudeMarketplaceRejectsEscapingRelativeSource(t *testing.T) {
|
||||
"kind": "plugin",
|
||||
})
|
||||
_, err := tl.Execute(context.Background(), raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "escapes") {
|
||||
if !errors.Is(err, ErrManifestMissing) {
|
||||
t.Fatalf("error = %v, want marketplace path escape rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -2210,7 +2210,7 @@ func TestGitHubClaudeMarketplaceSelectedUnsupportedSourceFails(t *testing.T) {
|
||||
"name": "external",
|
||||
})
|
||||
_, err := tl.Execute(context.Background(), raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "external source") {
|
||||
if !errors.Is(err, ErrManifestMissing) {
|
||||
t.Fatalf("error = %v, want external-source rejection for the selected plugin", err)
|
||||
}
|
||||
}
|
||||
@@ -2335,7 +2335,7 @@ func TestGitHubPluginApplyRefusesUnpinnableDrift(t *testing.T) {
|
||||
if len(resp.Actions) != 1 || resp.Actions[0].Status != "failed" {
|
||||
t.Fatalf("actions = %+v, want the single install action failed", resp.Actions)
|
||||
}
|
||||
if !strings.Contains(resp.Actions[0].Error, "approved commit cafe0001") {
|
||||
if !strings.Contains(resp.Actions[0].Error, ErrApprovalDenied.Error()) {
|
||||
t.Fatalf("action error = %q, want the approved-commit drift refusal", resp.Actions[0].Error)
|
||||
}
|
||||
if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "pwf"); ok {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
type unsupportedMarketplaceObject struct {
|
||||
@@ -38,7 +40,7 @@ func (t *Tool) marketplaceObjectSource(ctx context.Context, body json.RawMessage
|
||||
}
|
||||
if _, ok := parseGitHubRepoSource(strings.TrimSpace(pinned.URL)); !ok {
|
||||
return "", "", "", nil, &unsupportedMarketplaceObject{
|
||||
message: fmt.Sprintf("pinned URL %q is not a GitHub repository", pinned.URL),
|
||||
message: fmt.Sprintf("pinned URL %q is not a GitHub repository", secrets.RedactEndpoint(pinned.URL)),
|
||||
warning: "pinned URL is not a GitHub repository",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
@@ -66,9 +67,9 @@ func mcpActionRisk(e config.PluginEntry, reasons []string) (RiskLevel, []string)
|
||||
level := RiskMedium
|
||||
hasAuth := false
|
||||
for k, v := range e.Headers {
|
||||
if strings.EqualFold(k, "Authorization") || strings.Contains(strings.ToLower(v), "bearer") || strings.Contains(strings.ToLower(v), "token") {
|
||||
if secrets.CredentialKey(k) || secrets.CredentialValue(v) {
|
||||
hasAuth = true
|
||||
reasons = append(reasons, "sends auth headers to "+e.URL)
|
||||
reasons = append(reasons, "sends auth headers to "+secrets.RedactEndpoint(e.URL))
|
||||
}
|
||||
}
|
||||
if e.Tier == "eager" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
@@ -40,7 +41,7 @@ func TestRemoteMCPTransportUsesEndpointPath(t *testing.T) {
|
||||
ProjectRoot: project, HomeDir: testenv.TempDir(t), ConnectMCP: stub.connector(), RequireApprovedPlan: true,
|
||||
Approval: func(actions []action) error {
|
||||
approved++
|
||||
if len(actions) != 1 || actions[0].Transport != tc.want || actions[0].URL != tc.source {
|
||||
if len(actions) != 1 || actions[0].Transport != tc.want || actions[0].URL != secrets.RedactEndpoint(tc.source) {
|
||||
t.Fatalf("approved actions = %+v", actions)
|
||||
}
|
||||
return nil
|
||||
@@ -51,8 +52,8 @@ func TestRemoteMCPTransportUsesEndpointPath(t *testing.T) {
|
||||
if !plan.OK || plan.Status != "planned" || len(plan.Actions) != 1 || plan.PlanID == "" {
|
||||
t.Fatalf("plan = %+v", plan)
|
||||
}
|
||||
if plan.Actions[0].Transport != tc.want || plan.Actions[0].URL != tc.source {
|
||||
t.Fatalf("plan transport/url = %q %q, want %q %q", plan.Actions[0].Transport, plan.Actions[0].URL, tc.want, tc.source)
|
||||
if shown := secrets.RedactEndpoint(tc.source); plan.Actions[0].Transport != tc.want || plan.Actions[0].URL != shown {
|
||||
t.Fatalf("plan transport/url = %q %q, want %q %q", plan.Actions[0].Transport, plan.Actions[0].URL, tc.want, shown)
|
||||
}
|
||||
if approved != 0 || len(stub.connected) != 0 || len(config.LoadForEdit(filepath.Join(project, "reasonix.toml")).Plugins) != 0 {
|
||||
t.Fatal("preview approved, connected, or persisted a server")
|
||||
|
||||
@@ -603,9 +603,9 @@ func verifyCopiedCapabilities(src pluginpkg.Package, target string) error {
|
||||
is, ic, ih, im := installed.CapabilityCounts()
|
||||
sa, ia := src.AgentCount(), installed.AgentCount()
|
||||
if ss != is || sc != ic || sh != ih || sm != im || sa != ia {
|
||||
return newErr(ErrInvalidManifest,
|
||||
return &hostFactError{sentinel: ErrInvalidManifest, facts: fmt.Sprintf(
|
||||
"installed copy resolves to %d skills / %d agents / %d commands / %d hooks / %d MCP servers but the approved plan counted %d/%d/%d/%d/%d — the package likely uses symlinks copy mode cannot materialize safely; retry with mode=link or fix the package layout",
|
||||
is, ia, ic, ih, im, ss, sa, sc, sh, sm)
|
||||
is, ia, ic, ih, im, ss, sa, sc, sh, sm)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ func (t *Tool) fetchGitHubTarball(ctx context.Context, src githubRepoSource, dir
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return "", newErr(ErrAuthRequired, "%s: HTTP %d", sourceURL, resp.StatusCode)
|
||||
return "", &sourceHTTPError{sentinel: ErrAuthRequired, status: resp.StatusCode}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", newErr(ErrSourceUnreadable, "%s: HTTP %d", sourceURL, resp.StatusCode)
|
||||
return "", &sourceHTTPError{sentinel: ErrSourceUnreadable, status: resp.StatusCode}
|
||||
}
|
||||
root, err := unpackTarball(io.LimitReader(resp.Body, tarballFetchLimit), dir)
|
||||
if err != nil {
|
||||
@@ -111,11 +111,11 @@ func unpackTarball(r io.Reader, dir string) (string, error) {
|
||||
if name == root {
|
||||
continue
|
||||
}
|
||||
return "", newErr(ErrUnsupportedKind, "tarball entry %q escapes the archive root %q", head.Name, root)
|
||||
return "", &sourceBoundaryError{code: sourceEscape}
|
||||
}
|
||||
rel := filepath.FromSlash(rest)
|
||||
if !filepath.IsLocal(rel) {
|
||||
return "", newErr(ErrUnsupportedKind, "tarball entry %q escapes the extraction directory", head.Name)
|
||||
return "", &sourceBoundaryError{code: sourceEscape}
|
||||
}
|
||||
dest := filepath.Join(dir, rel)
|
||||
if head.Typeflag == tar.TypeDir {
|
||||
@@ -126,7 +126,7 @@ func unpackTarball(r io.Reader, dir string) (string, error) {
|
||||
}
|
||||
files++
|
||||
if files > tarballFileLimit {
|
||||
return "", newErr(ErrSourceUnreadable, "tarball holds more than %d files", tarballFileLimit)
|
||||
return "", &sourceBoundaryError{code: sourceFileCount, limit: tarballFileLimit}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", newErr(ErrSourceUnreadable, "create %s: %v", filepath.Dir(rel), err)
|
||||
@@ -137,7 +137,7 @@ func unpackTarball(r io.Reader, dir string) (string, error) {
|
||||
}
|
||||
total += written
|
||||
if total > tarballTotalLimit {
|
||||
return "", newErr(ErrSourceUnreadable, "tarball expands past %d bytes", tarballTotalLimit)
|
||||
return "", &sourceBoundaryError{code: sourceTotalSize, limit: tarballTotalLimit}
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
@@ -165,7 +165,7 @@ func writeTarEntry(dest string, r io.Reader, mode int64) (int64, error) {
|
||||
return written, newErr(ErrSourceUnreadable, "write %s: %v", filepath.Base(dest), err)
|
||||
}
|
||||
if written > tarballEntryLimit {
|
||||
return written, newErr(ErrSourceUnreadable, "%s is larger than %d bytes", filepath.Base(dest), tarballEntryLimit)
|
||||
return written, &sourceBoundaryError{code: sourceEntrySize, limit: tarballEntryLimit}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package installsource
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/pluginpkg"
|
||||
)
|
||||
@@ -62,6 +63,7 @@ type response struct {
|
||||
// ContentDigest is what a reviewer records to pin this exact material;
|
||||
// empty when the plan's material cannot be pinned.
|
||||
ContentDigest string `json:"contentDigest,omitempty"`
|
||||
failure error
|
||||
}
|
||||
|
||||
// kindTally reports per-kind counts. It is a struct (not a map) so the JSON
|
||||
@@ -134,6 +136,7 @@ type action struct {
|
||||
// actions finish.
|
||||
preparedRoot string
|
||||
cleanup func()
|
||||
failure error
|
||||
}
|
||||
|
||||
// RuntimePlanInfo describes a plugin package's declared runtime process in
|
||||
@@ -189,6 +192,15 @@ func publicActions(in []action) []action {
|
||||
out := make([]action, len(in))
|
||||
for i := range in {
|
||||
out[i] = in[i]
|
||||
out[i].Source = secrets.RedactConfigValue("", in[i].Source)
|
||||
out[i].Target = secrets.RedactConfigValue("", in[i].Target)
|
||||
out[i].URL = secrets.RedactEndpoint(in[i].URL)
|
||||
out[i].Command = secrets.RedactConfigValue("", in[i].Command)
|
||||
out[i].Args = secrets.RedactArgs(in[i].Args)
|
||||
out[i].Env = secrets.RedactConfigMap(in[i].Env)
|
||||
out[i].Headers = secrets.RedactConfigMap(in[i].Headers)
|
||||
out[i].Error = publicFailure(in[i].failure, in[i].Error)
|
||||
out[i].failure = nil
|
||||
out[i].entry = config.PluginEntry{}
|
||||
out[i].skill = skillCandidate{}
|
||||
out[i].preparedRoot = ""
|
||||
@@ -198,6 +210,18 @@ func publicActions(in []action) []action {
|
||||
}
|
||||
|
||||
func marshalJSON(v any) string {
|
||||
if result, ok := v.(response); ok {
|
||||
result.Source = secrets.RedactConfigValue("", result.Source)
|
||||
result.Error = publicFailure(result.failure, result.Error)
|
||||
v = result
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func publicFailure(failure error, text string) string {
|
||||
if failure != nil {
|
||||
return secrets.DiagnosticError(failure).Error()
|
||||
}
|
||||
return secrets.OmittedText(text)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,8 @@ import (
|
||||
// -- npx -y chrome-devtools-mcp@latest (name derived from the package)
|
||||
// https://example.com/mcp (name derived from the host)
|
||||
// <name> [command...|--http URL] [--env K=V] [--header K=V]
|
||||
func ParseArgs(args []string) (config.PluginEntry, error) {
|
||||
func ParseArgs(args []string) (entry config.PluginEntry, err error) {
|
||||
defer func() { err = secrets.DiagnosticError(err) }()
|
||||
var e config.PluginEntry
|
||||
if len(args) == 0 {
|
||||
return e, fmt.Errorf("mcp add: missing server name, command, or URL")
|
||||
@@ -113,7 +115,7 @@ func applyFlags(e *config.PluginEntry, rest []string) error {
|
||||
func putPair(dst *map[string]string, flag, pair string) error {
|
||||
k, v, ok := strings.Cut(pair, "=")
|
||||
if !ok || strings.TrimSpace(k) == "" {
|
||||
return fmt.Errorf("mcp add: %s expects KEY=VALUE, got %q", flag, pair)
|
||||
return fmt.Errorf("mcp add: %s expects KEY=VALUE, got %q", flag, secrets.EndpointRedacted)
|
||||
}
|
||||
if *dst == nil {
|
||||
*dst = map[string]string{}
|
||||
@@ -146,15 +148,20 @@ func NameFromURL(raw string) string {
|
||||
// NameFromArgv derives a server name from the command that starts it, looking
|
||||
// through the runner (npx/uvx/python -m/…) to the package it actually launches.
|
||||
func NameFromArgv(command string, args []string) string {
|
||||
shown := secrets.RedactArgs(args)
|
||||
runner := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(filepath.Base(strings.ReplaceAll(command, "\\", "/"))), ".exe"), ".cmd"), ".bat")
|
||||
candidate := command
|
||||
launcher, known := commandLaunchers[runner]
|
||||
at := -1
|
||||
if known {
|
||||
candidate = launcherCommandOperand(args, launcher)
|
||||
candidate, at = launcherCommandOperand(args, launcher)
|
||||
if candidate == "" {
|
||||
return "mcp-server"
|
||||
}
|
||||
}
|
||||
if candidate == secrets.EndpointRedacted || secrets.RedactConfigValue("", candidate) != candidate || known && !(at >= 0 && at < len(shown) && shown[at] == args[at]) {
|
||||
return "mcp-server"
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(candidate, "\\", "/"))
|
||||
if known && launcher.image {
|
||||
base, _, _ = strings.Cut(base, ":")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package mcpsetup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEndpointDisplayFailsClosed(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://user:fixture-secret@host/mcp",
|
||||
"HTTPS://user:fixture-secret@[::1]:8443/mcp",
|
||||
"https://host/mcp?password=fixture-secret",
|
||||
"https://host/mcp?passwd=fixture-secret",
|
||||
"https://host/mcp?%74oken=fixture-secret",
|
||||
"https://host/mcp#token=fixture-secret",
|
||||
"https://user:fixture-secret@host/mcp%zz",
|
||||
"https://host/mcp?token=fixture-secret;y=1",
|
||||
"https://host/mcp?token=fixture-secret%zz",
|
||||
"https://host/mcp#token=fixture-secret%zz",
|
||||
} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
got := RedactURL(raw)
|
||||
if strings.Contains(got, "fixture-secret") || strings.Contains(got, "user:") {
|
||||
t.Fatalf("credential reached display: %q", got)
|
||||
}
|
||||
if twice := RedactURL(got); twice != got {
|
||||
t.Fatalf("not idempotent: %q -> %q", got, twice)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{"https://[::1]:8443/mcp?author=neutral", "https://host/mcp?token="} {
|
||||
if got := RedactURL(raw); got != raw {
|
||||
t.Errorf("safe endpoint changed: %q -> %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialFieldsUseKeyBoundaries(t *testing.T) {
|
||||
for _, key := range []string{"PASSWORD", "db_passwd", "X-Api-Key", "Authorization", "Cookie", "access_token"} {
|
||||
if got := Redact(key, "fixture-secret"); got == "fixture-secret" {
|
||||
t.Errorf("%s leaked", key)
|
||||
}
|
||||
}
|
||||
if got := Redact("author", "neutral"); got != "neutral" {
|
||||
t.Fatalf("author is not a credential: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftRisksKeepOperationalCredentialsPrivate(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"HTTPS://user:fixture-secret@host/mcp",
|
||||
`node server --password fixture-secret --endpoint=https://host/mcp?%74oken=fixture-secret`,
|
||||
} {
|
||||
draft, err := Parse(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(draft.Entries[0].URL+strings.Join(draft.Entries[0].Args, " "), "fixture-secret") {
|
||||
t.Fatal("operational credential was lost")
|
||||
}
|
||||
for _, risk := range draft.Risks {
|
||||
if strings.Contains(risk.Detail, "fixture-secret") {
|
||||
t.Fatalf("risk leaked: %+v", risk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package mcpsetup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionDerivedNameExcludesCredentials(t *testing.T) {
|
||||
draft, err := Parse("node --header 'Authorization: Bearer fixturesecret'")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft.Entries[0].Args[1] != "Authorization: Bearer fixturesecret" {
|
||||
t.Fatal("operational argument changed")
|
||||
}
|
||||
b, _ := json.Marshal(draft.Risks)
|
||||
if strings.Contains(draft.Entries[0].Name, "fixturesecret") || strings.Contains(string(b), "fixturesecret") {
|
||||
t.Fatalf("derived display leaked: %s %s", draft.Entries[0].Name, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionDerivedNameExcludesOpaqueCarrierValues(t *testing.T) {
|
||||
for _, command := range []string{"node", "npx", "python", "uvx"} {
|
||||
if got := NameFromArgv(command, []string{"--header", "X-Custom: fixturesecret"}); got != "mcp-server" {
|
||||
t.Errorf("carrier became name: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionParseErrorMasksHeader(t *testing.T) {
|
||||
_, err := Parse("reasonix mcp add neutral --http https://host/mcp --header 'Authorization: Bearer fixturesecret'")
|
||||
if err == nil || strings.Contains(err.Error(), "fixturesecret") {
|
||||
t.Fatalf("parse error leaked: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ var commandLaunchers = map[string]commandLauncher{
|
||||
},
|
||||
}
|
||||
|
||||
func launcherCommandOperand(args []string, launcher commandLauncher) string {
|
||||
func launcherCommandOperand(args []string, launcher commandLauncher) (string, int) {
|
||||
options, needsCommand := true, len(launcher.commands) != 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
@@ -140,13 +140,13 @@ func launcherCommandOperand(args []string, launcher commandLauncher) string {
|
||||
if options && strings.HasPrefix(arg, "-") {
|
||||
kind, value, width, ok := launcherOption(arg, launcher)
|
||||
if !ok || i+width >= len(args) {
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
if width != 0 {
|
||||
value = args[i+width]
|
||||
}
|
||||
if kind == launcherModule {
|
||||
return value
|
||||
return value, i + width
|
||||
}
|
||||
i += width
|
||||
continue
|
||||
@@ -154,15 +154,15 @@ func launcherCommandOperand(args []string, launcher commandLauncher) string {
|
||||
if needsCommand {
|
||||
suffix, ok := launcher.commands[arg]
|
||||
if !ok || i+len(suffix) >= len(args) || !slices.Equal(args[i+1:i+1+len(suffix)], suffix) {
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
i += len(suffix)
|
||||
needsCommand = false
|
||||
continue
|
||||
}
|
||||
return arg
|
||||
return arg, i
|
||||
}
|
||||
return ""
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func launcherOption(arg string, launcher commandLauncher) (launcherFlag, string, int, bool) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package mcpsetup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNameFromArgvNeverDerivesFromMaskedValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"-y", "--api-key", "sk-live-0123456789", "pkg-server"},
|
||||
{"-y", "--token=tok-0123456789", "pkg-server"},
|
||||
{"-y", "-e", "API_KEY=sk-live-0123456789", "pkg-server"},
|
||||
} {
|
||||
if got := NameFromArgv("npx", args); strings.Contains(got, "0123456789") {
|
||||
t.Errorf("name for %v = %q carries a masked value", args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameFromArgvOperandIsTheShownArgument(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
command string
|
||||
args []string
|
||||
}{
|
||||
{"npx", []string{"--token", "s3cretvalue", "--name", "xs3cretvaluex"}},
|
||||
{"npx", []string{"--token", "s3cretvalue", "s3cretvalue-extra"}},
|
||||
{"uvx", []string{"--api-key", "s3cretvalue", "--from", "s3cretvalue2"}},
|
||||
} {
|
||||
if got := NameFromArgv(c.command, c.args); strings.Contains(got, "s3cretvalue") {
|
||||
t.Errorf("name for %v = %q carries a masked value", c.args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
@@ -44,7 +45,7 @@ func Parse(input string) (Draft, error) {
|
||||
entries, err = parseCommandLine(stripShellPrompt(trimmed))
|
||||
}
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
return Draft{}, secrets.DiagnosticError(err)
|
||||
}
|
||||
for i := range entries {
|
||||
if strings.TrimSpace(entries[i].Name) == "" {
|
||||
@@ -96,7 +97,7 @@ func risksFor(entries []config.PluginEntry) []Risk {
|
||||
// starts a process (or talks to a host) before judging the credential.
|
||||
if cmd := strings.TrimSpace(e.Command); cmd != "" {
|
||||
out = append(out, Risk{Server: e.Name, Kind: "shell", Field: "command",
|
||||
Detail: strings.TrimSpace(cmd + " " + strings.Join(e.Args, " "))})
|
||||
Detail: strings.TrimSpace(secrets.RedactConfigValue("", cmd) + " " + strings.Join(secrets.RedactArgs(e.Args), " "))})
|
||||
}
|
||||
if u := strings.TrimSpace(e.URL); u != "" {
|
||||
out = append(out, Risk{Server: e.Name, Kind: "unknown-host", Field: "url", Detail: RedactURL(u)})
|
||||
|
||||
@@ -2,77 +2,33 @@ package mcpsetup
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
// Redact replaces a value whose key or content looks like a credential. It is
|
||||
// what any surface printing an MCP config has to run first — a server block goes
|
||||
// into bug reports and screenshots far more often than it gets read once.
|
||||
func Redact(key, value string) string {
|
||||
if SensitiveKey(key) || SensitiveValue(value) {
|
||||
return "<redacted>"
|
||||
}
|
||||
return value
|
||||
return secrets.RedactConfigValue(key, value)
|
||||
}
|
||||
|
||||
// SensitiveKey reports whether a config key names a credential.
|
||||
func SensitiveKey(key string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(key))
|
||||
for _, needle := range []string{"auth", "token", "secret", "credential", "api_key", "api-key", "apikey", "cookie"} {
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SensitiveQueryKey adds the bare "key" query parameter, which is a credential
|
||||
// in a URL and an ordinary word anywhere else.
|
||||
func SensitiveQueryKey(key string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(key), "key") || SensitiveKey(key)
|
||||
return secrets.CredentialKey(key)
|
||||
}
|
||||
|
||||
// SensitiveValue reports whether a value carries a credential regardless of the
|
||||
// key it is filed under.
|
||||
func SensitiveValue(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
for _, needle := range []string{"access_token", "id_token", "refresh_token", "api_key", "api-key", "apikey", "bearer "} {
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return secrets.CredentialValue(value)
|
||||
}
|
||||
|
||||
// RedactURL masks credential-bearing query parameters while keeping the endpoint
|
||||
// readable — the host is the part the user needs to recognise.
|
||||
func RedactURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(trimmed)
|
||||
if err != nil || u == nil {
|
||||
if SensitiveValue(raw) {
|
||||
return "<redacted>"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
changed := false
|
||||
for key := range q {
|
||||
if SensitiveQueryKey(key) {
|
||||
q.Set(key, "<redacted>")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
return secrets.RedactEndpoint(raw)
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestNormalizeNameAvoidsSanitizedCollisions(t *testing.T) {
|
||||
|
||||
func TestSummarizeFailureErrorSingleLine(t *testing.T) {
|
||||
got := summarizeFailureError(errors.New("npm error code ENOTEMPTY\nnpm error path /tmp/x"))
|
||||
if strings.Contains(got, "\n") || !strings.Contains(got, "ENOTEMPTY") {
|
||||
if strings.Contains(got, "\n") || strings.Contains(got, "ENOTEMPTY") || !strings.Contains(got, "dependency failure") {
|
||||
t.Fatalf("summary = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFailureSummaryMasksEncodedEndpointKeys(t *testing.T) {
|
||||
err := &url.Error{Op: "Post", URL: "https://host/mcp?%74oken=fixture-secret#passwd=fixture-secret", Err: errors.New("connection unavailable")}
|
||||
if got := summarizeFailureError(err); strings.Contains(got, "fixture-secret") {
|
||||
t.Fatalf("summary leaked: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRereviewSSEAuxiliaryWarningsAndRPCIdentity(t *testing.T) {
|
||||
var sessions sync.Map
|
||||
var next atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Basic fixturesecret" {
|
||||
t.Error("operational header changed")
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
path := fmt.Sprintf("/messages/%d", next.Add(1))
|
||||
events := make(chan []byte, 8)
|
||||
sessions.Store(path, events)
|
||||
defer sessions.Delete(path)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", path)
|
||||
w.(http.Flusher).Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case body := <-events:
|
||||
_, _ = fmt.Fprintf(w, "event: message\ndata: %s\n\n", body)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if req.Method == "notifications/initialized" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
response := map[string]any{"jsonrpc": "2.0", "id": req.ID}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
response["result"] = map[string]any{"protocolVersion": protocolVersion, "serverInfo": map[string]any{"name": "neutral", "version": "0"}, "capabilities": map[string]any{"tools": map[string]any{}, "prompts": map[string]any{}, "resources": map[string]any{}}}
|
||||
case "tools/list":
|
||||
response["result"] = map[string]any{"tools": []any{}}
|
||||
default:
|
||||
response["error"] = map[string]any{"code": -32000, "message": "endpoint https://host/mcp?%74oken=fixturesecret"}
|
||||
}
|
||||
body, _ := json.Marshal(response)
|
||||
value, ok := sessions.Load(r.URL.Path)
|
||||
if !ok {
|
||||
t.Error("missing session")
|
||||
return
|
||||
}
|
||||
value.(chan []byte) <- body
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
spec := Spec{Name: "neutral", Type: "sse", URL: srv.URL + "/sse", Headers: map[string]string{"Authorization": "Basic fixturesecret"}}
|
||||
host, _, err := StartAll(ctx, []Spec{spec})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer host.Close()
|
||||
var logs bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
defer slog.SetDefault(previous)
|
||||
for _, fetch := range []func(context.Context, *Client){
|
||||
func(ctx context.Context, c *Client) { host.fetchPrompts(ctx, c, nil) },
|
||||
func(ctx context.Context, c *Client) { host.fetchResources(ctx, c, nil) },
|
||||
} {
|
||||
logs.Reset()
|
||||
fetch(ctx, host.clients[0])
|
||||
if logs.Len() == 0 || strings.Contains(logs.String(), "fixturesecret") {
|
||||
t.Errorf("auxiliary warning leaked: %s", logs.String())
|
||||
}
|
||||
}
|
||||
transport, err := newSSETransport(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transport.close()
|
||||
_, err = transport.call(ctx, "prompts/list", nil)
|
||||
var rpc *rpcError
|
||||
if !errors.As(err, &rpc) || rpc.Code != -32000 {
|
||||
t.Fatalf("RPC identity lost: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixturesecret") {
|
||||
t.Errorf("RPC diagnostic leaked: %v", err)
|
||||
}
|
||||
result, err := transport.call(ctx, "tools/list", nil)
|
||||
if err != nil || !strings.Contains(string(result), "tools") {
|
||||
t.Fatalf("successful result changed: %s %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionAuxiliaryWarningMasksStatusBody(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
writeHTTPRPCResult(w, req.ID, map[string]any{"protocolVersion": "2025-06-18", "serverInfo": map[string]any{"name": "neutral", "version": "0"}, "capabilities": map[string]any{"tools": map[string]any{}, "prompts": map[string]any{}}})
|
||||
case "tools/list":
|
||||
writeHTTPRPCResult(w, req.ID, map[string]any{"tools": []any{}})
|
||||
case "prompts/list":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Bearer rxprobe https://host" + r.URL.String()))
|
||||
default:
|
||||
writeHTTPRPCResult(w, req.ID, map[string]any{})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
host, _, err := StartAll(t.Context(), []Spec{{Name: "neutral", Type: "http", URL: srv.URL + "/mcp?%74oken=fixturesecret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer host.Close()
|
||||
host.fetchPrompts(t.Context(), host.clients[0], nil)
|
||||
if logs.Len() == 0 || strings.Contains(logs.String(), "fixturesecret") || strings.Contains(logs.String(), "rxprobe") {
|
||||
t.Fatalf("warning leaked: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionHTTPStatusErrorMasksEndpoint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("https://host" + r.URL.String()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
transport, err := newHTTPTransport(Spec{Name: "neutral", URL: srv.URL + "/mcp?%74oken=fixturesecret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transport.close()
|
||||
_, err = transport.call(t.Context(), "prompts/list", nil)
|
||||
var status *httpStatusError
|
||||
if !errors.As(err, &status) || status.Status != 500 {
|
||||
t.Fatalf("status identity lost: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixturesecret") {
|
||||
t.Fatalf("HTTP status leaked: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRound3RemoteBodiesOmitted(t *testing.T) {
|
||||
for _, body := range []string{"Basic rxprobe", "Bearer rxprobe", "arbitrary neutralprobe"} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
httpTr, err := newHTTPTransport(Spec{Name: "neutral", URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer httpTr.close()
|
||||
_, httpErr := httpTr.call(t.Context(), "prompts/list", nil)
|
||||
sseTr, err := newSSETransport(t.Context(), Spec{Name: "neutral", URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sseTr.close()
|
||||
sseErr := sseTr.waitEndpoint(t.Context())
|
||||
for _, failure := range []error{httpErr, sseErr} {
|
||||
var status *httpStatusError
|
||||
if !errors.As(failure, &status) || status.Status != http.StatusInternalServerError {
|
||||
t.Fatalf("status lost: %v", failure)
|
||||
}
|
||||
if strings.Contains(failure.Error(), body) || strings.Contains(failure.Error(), "rxprobe") || strings.Contains(failure.Error(), "neutralprobe") {
|
||||
t.Errorf("remote body survived: %v", failure)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRound3RPCAndStderrOmitted(t *testing.T) {
|
||||
rpc := &rpcError{Code: -32000, Message: "unstructured neutralprobe"}
|
||||
if strings.Contains(rpc.Error(), "neutralprobe") {
|
||||
t.Errorf("RPC prose survived: %s", rpc)
|
||||
}
|
||||
tr := &stdioTransport{stderr: &tailBuffer{limit: 1024}}
|
||||
_, _ = tr.stderr.Write([]byte("unstructured neutralprobe"))
|
||||
if got := tr.withStderr(errors.New("fixture exit")).Error(); strings.Contains(got, "neutralprobe") || !strings.Contains(got, fmt.Sprintf("%d bytes", len("unstructured neutralprobe"))) {
|
||||
t.Errorf("stderr facts = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type credentialFailureTransport struct{ cause error }
|
||||
|
||||
func (t credentialFailureTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.cause
|
||||
}
|
||||
|
||||
func TestHTTPTransportErrorsMaskURLAndPreserveIdentity(t *testing.T) {
|
||||
cause := errors.New("fixture transport unavailable")
|
||||
transport := &httpTransport{name: "neutral", url: "https://host/mcp?%74oken=fixture-secret", client: &http.Client{Transport: credentialFailureTransport{cause: cause}}}
|
||||
_, err := transport.call(context.Background(), "initialize", nil)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("identity lost: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixture-secret") {
|
||||
t.Fatalf("transport leaked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSETransportErrorsMaskURLAndPreserveIdentity(t *testing.T) {
|
||||
cause := errors.New("fixture transport unavailable")
|
||||
endpoint, err := url.Parse("https://host/mcp?%74oken=fixture-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport := &sseTransport{endpoint: endpoint, client: &http.Client{Transport: credentialFailureTransport{cause: cause}}}
|
||||
err = transport.post(context.Background(), nil)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("identity lost: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "fixture-secret") {
|
||||
t.Fatalf("SSE leaked: %v", err)
|
||||
}
|
||||
_, err = newSSETransport(context.Background(), Spec{Name: "neutral", URL: "https://user:fixture-secret@host/mcp%zz"})
|
||||
if err == nil || strings.Contains(err.Error(), "fixture-secret") {
|
||||
t.Fatalf("invalid SSE endpoint leaked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEEndpointParseFailureKeepsEveryDiagnosticRedacted(t *testing.T) {
|
||||
base, err := url.Parse("https://host/mcp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport := &sseTransport{pending: map[int]chan rpcResponse{}, endpointReady: make(chan struct{})}
|
||||
transport.handleEvent("endpoint", "https://user:fixture-secret@host/mcp%zz", base)
|
||||
for _, err := range []error{transport.waitEndpoint(context.Background()), transport.readErr} {
|
||||
if err == nil || strings.Contains(err.Error(), "fixture-secret") {
|
||||
t.Fatalf("SSE endpoint diagnostic leaked: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
var errOAuthTokenResponse = errors.New("MCP OAuth token response rejected")
|
||||
var errOAuthAuthorizationResponse = errors.New("MCP OAuth authorization rejected")
|
||||
var errOAuthRefreshMissing error = hostDiagnosticCause("MCP OAuth access token expired and no refresh token is available; authorize again")
|
||||
var errOAuthRefreshInvalidated error = hostDiagnosticCause("MCP OAuth token refresh was invalidated while contacting the token endpoint; authorize again")
|
||||
|
||||
type omittedStderrWriter struct{ sink io.Writer }
|
||||
|
||||
type hostDiagnosticCause string
|
||||
|
||||
func (e hostDiagnosticCause) Error() string { return string(e) }
|
||||
func (e hostDiagnosticCause) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type subprocessOutputError struct {
|
||||
cause error
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (e *subprocessOutputError) Error() string {
|
||||
return fmt.Sprintf("%s; subprocess output omitted (%d bytes)", secrets.DiagnosticError(e.cause), e.bytes)
|
||||
}
|
||||
func (e *subprocessOutputError) Unwrap() error { return e.cause }
|
||||
func (e *subprocessOutputError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type commandMissingError struct{ command string }
|
||||
|
||||
func (e *commandMissingError) Error() string {
|
||||
return fmt.Sprintf("command %q not found on PATH; use an absolute command path or set PATH in the MCP server env", secrets.RedactConfigValue("", e.command))
|
||||
}
|
||||
func (e *commandMissingError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type catalogPageError struct{ limit int }
|
||||
|
||||
type stdioReadFailure struct {
|
||||
method string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *stdioReadFailure) Error() string {
|
||||
if e.method == "" {
|
||||
return fmt.Sprintf("MCP subprocess read failed: %s", secrets.DiagnosticError(e.cause))
|
||||
}
|
||||
return fmt.Sprintf("server exited while handling %s; the next call starts a fresh one: %s", secrets.RedactConfigValue("", e.method), secrets.DiagnosticError(e.cause))
|
||||
}
|
||||
func (e *stdioReadFailure) Unwrap() error { return e.cause }
|
||||
func (e *stdioReadFailure) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
func (e *catalogPageError) Error() string {
|
||||
if e.limit == 0 {
|
||||
return "MCP catalog returned a cursor twice"
|
||||
}
|
||||
return fmt.Sprintf("MCP catalog did not finish within %d pages", e.limit)
|
||||
}
|
||||
func (e *catalogPageError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
func (w omittedStderrWriter) Write(p []byte) (int, error) {
|
||||
_, err := io.WriteString(w.sink, secrets.OmittedText(string(p))+"\n")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -34,6 +34,8 @@ func (e *launchApprovalError) Error() string {
|
||||
return fmt.Sprintf("project-provided MCP server %q is blocked before process or network startup until the user authorizes it", e.server)
|
||||
}
|
||||
|
||||
func (e *launchApprovalError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
func requiresLaunchApproval(err error) bool {
|
||||
var launchTarget *launchApprovalError
|
||||
return errors.As(err, &launchTarget)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestAFailureCarriesTheStatusItEndedAt(t *testing.T) {
|
||||
h := &Host{}
|
||||
h.RecordFailure(Spec{Name: "remote", Type: "http"},
|
||||
fmt.Errorf("plugin %q: initialize: %w", "remote", &httpStatusError{Status: http.StatusUnauthorized, Detail: "go away"}))
|
||||
fmt.Errorf("plugin %q: initialize: %w", "remote", &httpStatusError{Status: http.StatusUnauthorized, BodyBytes: 7}))
|
||||
|
||||
got := h.Failures()
|
||||
if len(got) != 1 {
|
||||
@@ -30,7 +30,7 @@ func TestAFailureCarriesTheStatusItEndedAt(t *testing.T) {
|
||||
func TestTheStatusIsNotReadOutOfTheMessage(t *testing.T) {
|
||||
h := &Host{}
|
||||
h.RecordFailure(Spec{Name: "a", Type: "http"},
|
||||
fmt.Errorf("plugin %q: %w", "a", &httpStatusError{Status: http.StatusForbidden, Detail: "401 unauthorized forbidden auth please login"}))
|
||||
fmt.Errorf("plugin %q: %w", "a", &httpStatusError{Status: http.StatusForbidden, BodyBytes: 43}))
|
||||
h.RecordFailure(Spec{Name: "b", Type: "stdio"}, errors.New("401 unauthorized forbidden auth please login"))
|
||||
|
||||
by := map[string]Failure{}
|
||||
|
||||
@@ -2,7 +2,9 @@ package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -59,6 +61,10 @@ func InstallResultForError(name string, err error) MCPInstallResult {
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
var status *httpStatusError
|
||||
if errors.As(err, &status) && (status.Status == http.StatusUnauthorized || status.Status == http.StatusForbidden) {
|
||||
return MCPInstallResult{Name: name, State: "action_required", Action: "authenticate", Message: msg}
|
||||
}
|
||||
if strings.Contains(lower, "auth") || strings.Contains(lower, "oauth") || strings.Contains(lower, "unauthorized") || strings.Contains(lower, "forbidden") {
|
||||
return MCPInstallResult{Name: name, State: "action_required", Action: "authenticate", Message: msg}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
func TestInstallResultClassifiesStatusByIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
state string
|
||||
action string
|
||||
}{
|
||||
{401, "action_required", "authenticate"},
|
||||
{403, "action_required", "authenticate"},
|
||||
{500, "issue", "retry"},
|
||||
} {
|
||||
err := secrets.DiagnosticError(fmt.Errorf("MCP startup initialize failed: %w", &httpStatusError{Status: tc.status, BodyBytes: 12}))
|
||||
if !errors.As(err, new(*httpStatusError)) {
|
||||
t.Fatalf("identity lost through the diagnostic wrapper: %v", err)
|
||||
}
|
||||
got := InstallResultForError("docs", err)
|
||||
if got.State != tc.state || got.Action != tc.action {
|
||||
t.Errorf("status %d: state=%q action=%q, want %q %q", tc.status, got.State, got.Action, tc.state, tc.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func (s *lazySpawn) run() {
|
||||
real, err := s.host.EnsureConnectedWithLifecycle(s.ctx, startupCtx, s.spec, s.generation)
|
||||
cancel()
|
||||
if err != nil {
|
||||
err = newStartupFailure("connect", started, "", err)
|
||||
err = newStartupFailure("connect", started, 0, err)
|
||||
}
|
||||
var cacheTools []tool.Tool
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -47,10 +47,10 @@ func listAllPages[T any](ctx context.Context, c *Client, method, field string) (
|
||||
return all, nil
|
||||
}
|
||||
if seen[next] {
|
||||
return nil, fmt.Errorf("plugin %q: %s returned cursor %q twice", c.name, method, next)
|
||||
return nil, &catalogPageError{}
|
||||
}
|
||||
seen[next] = true
|
||||
cursor = next
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q: %s did not finish within %d pages", c.name, method, maxListPages)
|
||||
return nil, &catalogPageError{limit: maxListPages}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/mcpdiag"
|
||||
)
|
||||
|
||||
@@ -494,7 +493,7 @@ func requestOAuthToken(ctx context.Context, client *http.Client, state mcpOAuthS
|
||||
return oauthTokenResponse{}, err
|
||||
}
|
||||
if token.Error != "" {
|
||||
return oauthTokenResponse{}, fmt.Errorf("%s: %s", secrets.RedactCredentials(token.Error), secrets.RedactCredentials(token.Description))
|
||||
return oauthTokenResponse{}, errOAuthTokenResponse
|
||||
}
|
||||
if strings.TrimSpace(token.AccessToken) == "" {
|
||||
return oauthTokenResponse{}, fmt.Errorf("token response has no access_token")
|
||||
@@ -549,7 +548,7 @@ func oauthCallbackHandler(expect oauthCallbackExpect, result chan<- oauthCallbac
|
||||
case !issuerMatches(query, expect):
|
||||
callback.Err = ErrOAuthIssuerMismatch
|
||||
case query.Get("error") != "":
|
||||
callback.Err = fmt.Errorf("MCP OAuth authorization failed: %s: %s", secrets.RedactCredentials(query.Get("error")), secrets.RedactCredentials(query.Get("error_description")))
|
||||
callback.Err = errOAuthAuthorizationResponse
|
||||
case strings.TrimSpace(query.Get("code")) == "":
|
||||
callback.Err = fmt.Errorf("MCP OAuth callback did not include an authorization code")
|
||||
default:
|
||||
@@ -711,7 +710,7 @@ func decodeLimitedJSON(r io.Reader, out any) error {
|
||||
|
||||
func oauthHTTPError(action string, resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("%s: HTTP %d: %s", action, resp.StatusCode, secrets.RedactCredentials(strings.TrimSpace(string(body))))
|
||||
return fmt.Errorf("%s: %w", action, &httpStatusError{Status: resp.StatusCode, BodyBytes: len(body)})
|
||||
}
|
||||
|
||||
func newOAuthHTTPClient(base *http.Client) *http.Client {
|
||||
|
||||
@@ -50,7 +50,7 @@ func (c *mcpOAuthClient) refresh(ctx context.Context, rejected *string) error {
|
||||
}
|
||||
if !c.canRefresh() {
|
||||
release()
|
||||
return fmt.Errorf("MCP OAuth access token expired and no refresh token is available; authorize again")
|
||||
return errOAuthRefreshMissing
|
||||
}
|
||||
refreshState := latest
|
||||
generation, err := loadMCPOAuthGeneration(c.stateDir)
|
||||
@@ -89,7 +89,7 @@ func (c *mcpOAuthClient) refresh(ctx context.Context, rejected *string) error {
|
||||
}
|
||||
if currentGeneration != generation || !sameOAuthRefreshState(current, refreshState) {
|
||||
if currentGeneration != generation {
|
||||
return fmt.Errorf("MCP OAuth token refresh was invalidated while contacting the token endpoint; authorize again")
|
||||
return errOAuthRefreshInvalidated
|
||||
}
|
||||
if oauthAccessTokenUsable(current, time.Now()) {
|
||||
c.state = current
|
||||
|
||||
@@ -106,9 +106,7 @@ type Spec struct {
|
||||
// stdio servers keep inheriting Reasonix's cwd while still receiving the
|
||||
// explicit workspace root when they ask for roots/list.
|
||||
WorkspaceRoot string
|
||||
// Stderr optionally mirrors plugin subprocess stderr output. Stderr is always
|
||||
// captured in a bounded buffer for failure diagnostics; nil keeps it out of
|
||||
// the terminal so child logs cannot corrupt interactive UIs.
|
||||
// Stderr receives byte-count summaries because raw child logs can carry credentials.
|
||||
Stderr io.Writer
|
||||
// LaunchManager owns exact project launch grants and mutable launcher locks.
|
||||
// It never contributes to SchemaCacheKey or provider-visible tool schemas.
|
||||
@@ -509,14 +507,14 @@ func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink) {
|
||||
func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) {
|
||||
aux, err := c.auxiliaryClient(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: start auxiliary prompt client failed", "server", c.name, "err", err)
|
||||
slog.Warn("plugin: start auxiliary prompt client failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
||||
return
|
||||
}
|
||||
defer aux.close()
|
||||
|
||||
ps, err := aux.listPrompts(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: listPrompts failed", "server", c.name, "err", err)
|
||||
slog.Warn("plugin: listPrompts failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
||||
return
|
||||
}
|
||||
for i := range ps {
|
||||
@@ -537,14 +535,14 @@ func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) {
|
||||
func (h *Host) fetchResources(ctx context.Context, c *Client, sink event.Sink) {
|
||||
aux, err := c.auxiliaryClient(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: start auxiliary resource client failed", "server", c.name, "err", err)
|
||||
slog.Warn("plugin: start auxiliary resource client failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
||||
return
|
||||
}
|
||||
defer aux.close()
|
||||
|
||||
rs, err := aux.listResources(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: listResources failed", "server", c.name, "err", err)
|
||||
slog.Warn("plugin: listResources failed", "server", c.name, "err", secrets.DiagnosticError(err))
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
@@ -728,7 +726,7 @@ func (h *Host) EnsureConnectedInBackground(lifeCtx context.Context, s Spec) <-ch
|
||||
tools, err := h.EnsureConnectedWithLifecycle(lifeCtx, startupCtx, s, generation)
|
||||
cancelStartup()
|
||||
if err != nil {
|
||||
err = newStartupFailure("connect", started, "", err)
|
||||
err = newStartupFailure("connect", started, 0, err)
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrDeferredSpawnCancelled) {
|
||||
h.RecordFailure(s, err)
|
||||
}
|
||||
@@ -1182,8 +1180,8 @@ var ErrDeferredSpawnCancelled = errors.New("deferred MCP spawn cancelled")
|
||||
// the mechanism and not one of the two things it could mean — and those two
|
||||
// have different things to do next.
|
||||
var (
|
||||
ErrHostClosed = errors.New("the MCP host shut down (session ended or runtime rebuilt)")
|
||||
ErrServerRemoved = errors.New("this MCP server was removed, disabled, or reconnected")
|
||||
ErrHostClosed error = hostDiagnosticCause("the MCP host shut down (session ended or runtime rebuilt)")
|
||||
ErrServerRemoved error = hostDiagnosticCause("this MCP server was removed, disabled, or reconnected")
|
||||
)
|
||||
|
||||
// start opens the transport on lifeCtx (whose cancellation later closes the
|
||||
@@ -1198,11 +1196,11 @@ func start(lifeCtx, callCtx context.Context, s Spec) (*Client, error) {
|
||||
var err error
|
||||
s, err = applyStoredLauncherLock(s)
|
||||
if err != nil {
|
||||
return nil, newStartupFailure("launch", started, "", err)
|
||||
return nil, newStartupFailure("launch", started, 0, err)
|
||||
}
|
||||
s, err = resolveProjectLaunchAuthorization(callCtx, s)
|
||||
if err != nil {
|
||||
return nil, newStartupFailure("authorization", started, "", err)
|
||||
return nil, newStartupFailure("authorization", started, 0, err)
|
||||
}
|
||||
t, err := newTransport(lifeCtx, s)
|
||||
if err != nil {
|
||||
@@ -1214,7 +1212,7 @@ func start(lifeCtx, callCtx context.Context, s Spec) (*Client, error) {
|
||||
err = cause
|
||||
}
|
||||
}
|
||||
return nil, newStartupFailure("launch", started, "", err)
|
||||
return nil, newStartupFailure("launch", started, 0, err)
|
||||
}
|
||||
tt := strings.ToLower(strings.TrimSpace(s.Type))
|
||||
if tt == "" {
|
||||
@@ -1364,7 +1362,7 @@ func (c *Client) listTools(ctx context.Context) ([]tool.Tool, error) {
|
||||
}
|
||||
headers, err := c.toolParamHeaders(t.InputSchema)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: tool left out for its header annotations", "server", c.name, "tool", t.Name, "err", err)
|
||||
slog.Warn("plugin: tool left out for its header annotations", "server", c.name, "tool", t.Name, "err", secrets.DiagnosticError(err))
|
||||
info.SchemaError = err.Error()
|
||||
toolInfos = append(toolInfos, info)
|
||||
continue
|
||||
@@ -1526,7 +1524,7 @@ func shortNameHash(s string) string {
|
||||
}
|
||||
|
||||
func summarizeFailureError(err error) string {
|
||||
msg := strings.Join(strings.Fields(secrets.RedactCredentials(err.Error())), " ")
|
||||
msg := strings.Join(strings.Fields(secrets.DiagnosticError(err).Error()), " ")
|
||||
const max = 500
|
||||
if len(msg) > max {
|
||||
msg = msg[:max] + "..."
|
||||
@@ -1550,14 +1548,6 @@ type rpcResponse struct {
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// remote tool adapter
|
||||
|
||||
type remoteTool struct {
|
||||
|
||||
@@ -776,7 +776,7 @@ func TestStdioFailureCapturesStderr(t *testing.T) {
|
||||
if len(failures) != 1 {
|
||||
t.Fatalf("failures = %+v, want one", failures)
|
||||
}
|
||||
if !strings.Contains(failures[0].Error, "helper stderr boom") {
|
||||
if strings.Contains(failures[0].Error, "helper stderr boom") || !strings.Contains(failures[0].Stderr, "output omitted") {
|
||||
t.Fatalf("failure should include stderr, got %q", failures[0].Error)
|
||||
}
|
||||
}
|
||||
@@ -801,7 +801,7 @@ func TestStdioFailureDecodesCodePageStderr(t *testing.T) {
|
||||
if len(failures) != 1 {
|
||||
t.Fatalf("failures = %+v, want one", failures)
|
||||
}
|
||||
if !strings.Contains(failures[0].Error, codePageNotFound) {
|
||||
if strings.Contains(failures[0].Error, codePageNotFound) || !strings.Contains(failures[0].Stderr, "output omitted") {
|
||||
t.Fatalf("failure should carry the decoded stderr, got %q", failures[0].Error)
|
||||
}
|
||||
}
|
||||
@@ -842,14 +842,14 @@ func TestStartupFailureReportsStageElapsedAndRedactedStderr(t *testing.T) {
|
||||
if len(failures) != 1 || failures[0].Stage != "initialize" || failures[0].Elapsed <= 0 {
|
||||
t.Fatalf("structured startup failure = %+v", failures)
|
||||
}
|
||||
if stderr := failures[0].Stderr; stderr != "" && !strings.Contains(stderr, "Bearer [redacted]") {
|
||||
if stderr := failures[0].Stderr; stderr != "" && !strings.Contains(stderr, "output omitted") {
|
||||
t.Fatalf("structured stderr was not redacted: %+v", failures[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureSummaryRedactsCredentials(t *testing.T) {
|
||||
got := summarizeFailureError(errors.New("startup failed: Authorization: Bearer summary-secret-value"))
|
||||
if strings.Contains(got, "summary-secret-value") || !strings.Contains(got, "Bearer [redacted]") {
|
||||
if strings.Contains(got, "summary-secret-value") || !strings.Contains(got, "dependency failure") {
|
||||
t.Fatalf("failure summary was not redacted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string {
|
||||
return fmt.Sprintf("rpc error %d (message omitted; %d bytes)", e.Code, len(e.Message))
|
||||
}
|
||||
|
||||
func (e *rpcError) DiagnosticFacts() string { return e.Error() }
|
||||
@@ -65,7 +65,9 @@ func (e *startupFailure) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func newStartupFailure(stage string, started time.Time, stderr string, err error) error {
|
||||
func (e *startupFailure) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
func newStartupFailure(stage string, started time.Time, stderrBytes int, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -74,11 +76,19 @@ func newStartupFailure(stage string, started time.Time, stderr string, err error
|
||||
return err
|
||||
}
|
||||
elapsed := max(time.Since(started), 0)
|
||||
stderr := ""
|
||||
var output *subprocessOutputError
|
||||
if errors.As(err, &output) {
|
||||
stderrBytes = output.bytes
|
||||
}
|
||||
if stderrBytes > 0 {
|
||||
stderr = fmt.Sprintf("subprocess output omitted (%d bytes)", stderrBytes)
|
||||
}
|
||||
return &startupFailure{
|
||||
Stage: strings.TrimSpace(stage),
|
||||
Elapsed: elapsed,
|
||||
Stderr: secrets.RedactCredentials(strings.TrimSpace(stderr)),
|
||||
Err: err,
|
||||
Stderr: stderr,
|
||||
Err: secrets.DiagnosticError(err),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,15 +119,15 @@ func formatElapsed(elapsed time.Duration) string {
|
||||
}
|
||||
|
||||
type startupDiagnosticTransport interface {
|
||||
startupStderr() string
|
||||
startupStderr() int
|
||||
}
|
||||
|
||||
func (c *Client) startupStderr() string {
|
||||
func (c *Client) startupStderr() int {
|
||||
if c == nil || c.t == nil {
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
if diagnostic, ok := c.t.(startupDiagnosticTransport); ok {
|
||||
return diagnostic.startupStderr()
|
||||
}
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/mcpdiag"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
@@ -108,6 +109,7 @@ func sameHTTPOrigin(a, b *url.URL) bool {
|
||||
}
|
||||
|
||||
func (t *httpTransport) call(ctx context.Context, method string, params any) (result json.RawMessage, err error) {
|
||||
defer func() { err = secrets.DiagnosticError(err) }()
|
||||
id := t.nextRequestID()
|
||||
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
|
||||
if err != nil {
|
||||
@@ -132,17 +134,16 @@ func (t *httpTransport) call(ctx context.Context, method string, params any) (re
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := strings.TrimSpace(string(b))
|
||||
// The spec's rule, whatever the body says: a 404 to a request that
|
||||
// carried a session id means that session is gone.
|
||||
if resp.StatusCode == http.StatusNotFound && heldSession {
|
||||
t.clearSession()
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, &httpSessionExpiredError{
|
||||
status: resp.StatusCode,
|
||||
body: msg,
|
||||
status: resp.StatusCode,
|
||||
bodyBytes: len(b),
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, &httpStatusError{Status: resp.StatusCode, Detail: msg, RPC: bodyRPCError(b)})
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, &httpStatusError{Status: resp.StatusCode, BodyBytes: len(b), RPC: bodyRPCError(b)})
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
|
||||
@@ -236,7 +237,7 @@ func (t *httpTransport) do(ctx context.Context, body []byte) (*http.Response, er
|
||||
func (t *httpTransport) doOAuth(ctx context.Context, body []byte, refreshed bool, modern http.Header) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, secrets.DiagnosticError(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
@@ -268,7 +269,7 @@ func (t *httpTransport) doOAuth(ctx context.Context, body []byte, refreshed bool
|
||||
maps.Copy(req.Header, modern)
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil || refreshed || resp.StatusCode != http.StatusUnauthorized || !usedOAuth || !t.oauth.canRefresh() {
|
||||
return resp, err
|
||||
return resp, secrets.DiagnosticError(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if _, _, err := t.oauth.authorizationHeaderAfterReject(ctx, sent); err != nil {
|
||||
@@ -291,8 +292,8 @@ func (t *httpTransport) captureSession(resp *http.Response) {
|
||||
// row — was left reading the digits back out of prose an external server had a
|
||||
// hand in writing.
|
||||
type httpStatusError struct {
|
||||
Status int
|
||||
Detail string
|
||||
Status int
|
||||
BodyBytes int
|
||||
// RPC is the JSON-RPC error the body carried, when it carried one: a
|
||||
// modern server answers 400 with a typed error a caller has to tell apart.
|
||||
RPC *rpcError
|
||||
@@ -306,24 +307,22 @@ func (e *httpStatusError) Unwrap() error {
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string {
|
||||
if e.Detail == "" {
|
||||
return fmt.Sprintf("http %d", e.Status)
|
||||
}
|
||||
return fmt.Sprintf("http %d: %s", e.Status, e.Detail)
|
||||
return fmt.Sprintf("http %d (response body omitted; read %d bytes)", e.Status, e.BodyBytes)
|
||||
}
|
||||
|
||||
func (e *httpStatusError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
type httpSessionExpiredError struct {
|
||||
status int
|
||||
body string
|
||||
status int
|
||||
bodyBytes int
|
||||
}
|
||||
|
||||
func (e *httpSessionExpiredError) Error() string {
|
||||
if e.body == "" {
|
||||
return fmt.Sprintf("http %d: MCP session expired", e.status)
|
||||
}
|
||||
return fmt.Sprintf("http %d: %s", e.status, e.body)
|
||||
return fmt.Sprintf("http %d: MCP session expired (response body omitted; read %d bytes)", e.status, e.bodyBytes)
|
||||
}
|
||||
|
||||
func (e *httpSessionExpiredError) DiagnosticFacts() string { return e.Error() }
|
||||
|
||||
// readSSEResponse scans an SSE stream for the JSON-RPC response matching id,
|
||||
// skipping server notifications and any other-id messages. Per the SSE spec,
|
||||
// consecutive data: lines within one event are joined with "\n" and an event is
|
||||
|
||||
@@ -30,10 +30,8 @@ func TestATerminalHTTPFailureKeepsItsStatusAsANumber(t *testing.T) {
|
||||
if status.Status != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", status.Status)
|
||||
}
|
||||
// The sentence is unchanged. Anything downstream that only displays it —
|
||||
// diagnostics, a redaction pass, a settings row — sees what it saw before.
|
||||
if !strings.Contains(err.Error(), "http 401: go away") {
|
||||
t.Errorf("message = %q, want it to still read http 401 with the detail", err.Error())
|
||||
if strings.Contains(err.Error(), "go away") || !strings.Contains(err.Error(), "http 401") {
|
||||
t.Errorf("message = %q, want status without remote detail", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -390,7 +391,8 @@ func TestHTTPTransportRPCError(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
_, _, err := StartAll(ctx, []Spec{{Name: "e", Type: "http", URL: srv.URL}})
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
var rpc *rpcError
|
||||
if err == nil || !errors.As(err, &rpc) || rpc.Code != -32000 || strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("want initialize to fail with rpc error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +266,13 @@ func (t *reconnectingTransport) rebindProgressLocked(next transport) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *reconnectingTransport) startupStderr() string {
|
||||
func (t *reconnectingTransport) startupStderr() int {
|
||||
active, err := t.current()
|
||||
if err != nil {
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
if diagnostic, ok := active.(startupDiagnosticTransport); ok {
|
||||
return diagnostic.startupStderr()
|
||||
}
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func newSSETransport(ctx context.Context, s Spec) (*sseTransport, error) {
|
||||
}
|
||||
getURL, err := url.Parse(s.URL)
|
||||
if err != nil || getURL.Scheme == "" || getURL.Host == "" {
|
||||
return nil, fmt.Errorf("sse plugin %q: invalid url %q", s.Name, s.URL)
|
||||
return nil, fmt.Errorf("sse plugin %q: invalid url %q", s.Name, secrets.RedactEndpoint(s.URL))
|
||||
}
|
||||
headers := make(map[string]string, len(s.Headers))
|
||||
maps.Copy(headers, s.Headers)
|
||||
@@ -114,11 +115,11 @@ func (t *sseTransport) readLoop() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
t.fail(fmt.Errorf("GET %s: %w", t.getURL, &httpStatusError{Status: resp.StatusCode, Detail: strings.TrimSpace(string(body))}))
|
||||
t.fail(fmt.Errorf("GET %s: %w", secrets.RedactEndpoint(t.getURL.String()), &httpStatusError{Status: resp.StatusCode, BodyBytes: len(body)}))
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
|
||||
t.fail(fmt.Errorf("GET %s: expected text/event-stream, got %q", t.getURL, resp.Header.Get("Content-Type")))
|
||||
t.fail(errSSEContentType)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,7 +173,7 @@ func (t *sseTransport) handleEvent(eventName, payload string, baseURL *url.URL)
|
||||
if err == nil {
|
||||
endpoint = baseURL.ResolveReference(endpoint)
|
||||
if !sameHTTPOrigin(baseURL, endpoint) {
|
||||
err = fmt.Errorf("server announced cross-origin endpoint %s", endpoint)
|
||||
err = errSSECrossOrigin
|
||||
}
|
||||
}
|
||||
t.setEndpoint(endpoint, err)
|
||||
@@ -186,7 +187,7 @@ func (t *sseTransport) setEndpoint(endpoint *url.URL, err error) {
|
||||
t.endpointOnce.Do(func() {
|
||||
t.mu.Lock()
|
||||
t.endpoint = endpoint
|
||||
t.endpointErr = err
|
||||
t.endpointErr = secrets.DiagnosticError(err)
|
||||
t.mu.Unlock()
|
||||
close(t.endpointReady)
|
||||
set = true
|
||||
@@ -252,7 +253,8 @@ func (t *sseTransport) replyLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *sseTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
func (t *sseTransport) call(ctx context.Context, method string, params any) (result json.RawMessage, err error) {
|
||||
defer func() { err = secrets.DiagnosticError(err) }()
|
||||
if err := t.waitEndpoint(ctx); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
|
||||
}
|
||||
@@ -332,6 +334,8 @@ func (t *sseTransport) waitEndpoint(ctx context.Context) error {
|
||||
}
|
||||
|
||||
var errSSEEndpointMissing = errors.New("SSE stream ended before announcing an endpoint")
|
||||
var errSSEContentType = errors.New("SSE response has an unexpected content type")
|
||||
var errSSECrossOrigin error = hostDiagnosticCause("server announced a cross-origin endpoint")
|
||||
|
||||
func (t *sseTransport) post(ctx context.Context, body []byte) error {
|
||||
t.mu.Lock()
|
||||
@@ -342,7 +346,7 @@ func (t *sseTransport) post(ctx context.Context, body []byte) error {
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
return secrets.DiagnosticError(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
@@ -351,17 +355,18 @@ func (t *sseTransport) post(ctx context.Context, body []byte) error {
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return secrets.DiagnosticError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &httpStatusError{Status: resp.StatusCode, Detail: strings.TrimSpace(string(responseBody))}
|
||||
return &httpStatusError{Status: resp.StatusCode, BodyBytes: len(responseBody)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *sseTransport) fail(err error) {
|
||||
err = secrets.DiagnosticError(err)
|
||||
t.endpointOnce.Do(func() {
|
||||
t.mu.Lock()
|
||||
t.endpointErr = err
|
||||
@@ -372,6 +377,7 @@ func (t *sseTransport) fail(err error) {
|
||||
}
|
||||
|
||||
func (t *sseTransport) failPending(err error) {
|
||||
err = secrets.DiagnosticError(err)
|
||||
t.mu.Lock()
|
||||
if t.readErr == nil {
|
||||
t.readErr = err
|
||||
|
||||
@@ -110,7 +110,7 @@ func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
|
||||
stderr := &tailBuffer{limit: 16 * 1024}
|
||||
cmd.Stderr = stderr
|
||||
if s.Stderr != nil {
|
||||
cmd.Stderr = io.MultiWriter(stderr, s.Stderr)
|
||||
cmd.Stderr = io.MultiWriter(stderr, omittedStderrWriter{sink: s.Stderr})
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
@@ -279,12 +279,10 @@ func resolveStdioExecutable(ctx context.Context, s Spec, env []string) (string,
|
||||
return exe, fallbackEnv, nil
|
||||
}
|
||||
env = fallbackEnv
|
||||
currentPath = fallbackPath
|
||||
}
|
||||
}
|
||||
|
||||
return "", env, fmt.Errorf("stdio plugin %q: command %q not found on PATH; GUI launches and non-interactive sessions may not inherit your shell PATH. Use an absolute command path or set PATH in the MCP server env. PATH=%q",
|
||||
s.Name, s.Command, currentPath)
|
||||
return "", env, &commandMissingError{command: s.Command}
|
||||
}
|
||||
|
||||
// stdioWorkingDir keeps WorkspaceRoot's roots/list role separate from process
|
||||
@@ -569,7 +567,7 @@ func (t *stdioTransport) call(ctx context.Context, method string, params any) (j
|
||||
t.mu.Unlock()
|
||||
// Nothing was written for this request, so a replacement connection may
|
||||
// carry it. The mid-flight case below stays unmarked.
|
||||
return nil, markGone(t.withStderr(fmt.Errorf("plugin %q: read: %w", t.name, t.readErr)))
|
||||
return nil, markGone(t.withStderr(&stdioReadFailure{cause: t.readErr}))
|
||||
}
|
||||
t.nextID++
|
||||
id := t.nextID
|
||||
@@ -594,7 +592,7 @@ func (t *stdioTransport) call(ctx context.Context, method string, params any) (j
|
||||
case resp, ok := <-ch:
|
||||
if !ok {
|
||||
// A quiet death leaves only the startup banner, which alone misreads.
|
||||
return nil, t.withStderr(fmt.Errorf("plugin %q: server exited while handling %s; the next call starts a fresh one: %w", t.name, method, t.readErr))
|
||||
return nil, t.withStderr(&stdioReadFailure{method: method, cause: t.readErr})
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", t.name, resp.Error)
|
||||
@@ -629,21 +627,18 @@ func (t *stdioTransport) withStderr(err error) error {
|
||||
// close), and an unbounded wait here would strand the call that is trying
|
||||
// to report why the server went away.
|
||||
waitWithBudget(t.wait, closeWaitBudget)
|
||||
// This error is returned directly to callers outside startup as well as
|
||||
// copied into diagnostics. Redact at the transport boundary so an early
|
||||
// child exit cannot bypass the startup-specific redaction layer.
|
||||
msg := secrets.RedactCredentials(t.stderr.String())
|
||||
if msg == "" {
|
||||
count := len(t.stderr.String())
|
||||
if count == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w; last stderr: %s", err, msg) // the tail, not the startup log
|
||||
return &subprocessOutputError{cause: err, bytes: count}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) startupStderr() string {
|
||||
func (t *stdioTransport) startupStderr() int {
|
||||
if t == nil || t.stderr == nil {
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
return secrets.RedactCredentials(t.stderr.String())
|
||||
return len(t.stderr.String())
|
||||
}
|
||||
|
||||
// wait reaps the child exactly once; cmd.Wait blocks until the stderr-copy
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestStdioServerRecoversAfterChildDies(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("the call that kills the child should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), startupLine) {
|
||||
t.Fatalf("error should carry the child's last words, got %v", err)
|
||||
if strings.Contains(err.Error(), startupLine) || !strings.Contains(err.Error(), "output omitted") {
|
||||
t.Fatalf("error should omit the child's output, got %v", err)
|
||||
}
|
||||
// The last words of a server that died quietly are its startup banner, so
|
||||
// the message has to say which of the two happened — a bare "read: EOF"
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestWithStderrRedactsCredentials(t *testing.T) {
|
||||
tr := &stdioTransport{stderr: &tailBuffer{limit: 1024}}
|
||||
_, _ = tr.stderr.Write([]byte("Authorization: Bearer transport-secret-value"))
|
||||
got := tr.withStderr(errors.New("child exited")).Error()
|
||||
if strings.Contains(got, "transport-secret-value") || !strings.Contains(got, "Bearer [redacted]") {
|
||||
if strings.Contains(got, "transport-secret-value") || !strings.Contains(got, "output omitted") {
|
||||
t.Fatalf("stderr error was not redacted: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportMasksEndpointAndArgumentCredentials(t *testing.T) {
|
||||
for _, endpoint := range []string{
|
||||
"HTTPS://user:fixture-secret@[::1]:8443/mcp",
|
||||
"https://host/mcp?%74oken=fixture-secret",
|
||||
"https://host/mcp#password=fixture-secret",
|
||||
"https://host/mcp?token=fixture-secret;y=1",
|
||||
"https://host/mcp?token=fixture-secret%zz",
|
||||
} {
|
||||
raw := `{"mcpServers":{"neutral":{"url":"` + endpoint + `","command":"node","args":["--passwd","fixture-secret","--token=fixture-secret","` + endpoint + `","--author=neutral"],"env":{"PASSWORD":"fixture-secret"},"headers":{"Authorization":"fixture-secret"}}}}`
|
||||
out, _, err := StripCredentials([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "fixture-secret") {
|
||||
t.Fatalf("export leaked: %s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), "--author=neutral") {
|
||||
t.Fatal("ordinary argument changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMCPInventoryProjectsCredentialsWithoutChangingManifest(t *testing.T) {
|
||||
raw := "https://user:fixture-secret@host/mcp?%74oken=fixture-secret"
|
||||
pkg := Package{Manifest: Manifest{MCPServers: map[string]MCPServer{"neutral": {URL: raw}}}}
|
||||
refs := pkg.mcpServerRefs()
|
||||
if len(refs) != 1 || strings.Contains(refs[0].URL, "fixture-secret") {
|
||||
t.Fatalf("inventory leaked: %+v", refs)
|
||||
}
|
||||
if pkg.Manifest.MCPServers["neutral"].URL != raw {
|
||||
t.Fatal("operational manifest mutated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionExportCommandCredentials(t *testing.T) {
|
||||
for _, command := range []string{"node --header 'Authorization: Bearer fixturesecret'", "node --env 'KEY=fixturesecret'"} {
|
||||
out, _, err := StripCredentials([]byte(`{"mcpServers":{"neutral":{"command":"` + command + `"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "fixturesecret") {
|
||||
t.Fatalf("command leaked: %s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
// ExportSizeLimit caps the packed bytes. A plugin root is arbitrary user
|
||||
@@ -158,8 +160,28 @@ func stripNode(node any, scope string, required map[string]bool) {
|
||||
return
|
||||
}
|
||||
for key, value := range obj {
|
||||
switch key {
|
||||
case "mcpServers":
|
||||
switch {
|
||||
case strings.EqualFold(key, "command"):
|
||||
if command, ok := value.(string); ok {
|
||||
obj[key] = secrets.RedactConfigValue("", command)
|
||||
}
|
||||
case strings.EqualFold(key, "url"):
|
||||
if endpoint, ok := value.(string); ok {
|
||||
obj[key] = secrets.RedactEndpoint(endpoint)
|
||||
}
|
||||
case strings.EqualFold(key, "args"):
|
||||
if args, ok := value.([]any); ok {
|
||||
text := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
text[i], _ = arg.(string)
|
||||
}
|
||||
for i, arg := range secrets.RedactArgs(text) {
|
||||
if _, ok := args[i].(string); ok {
|
||||
args[i] = arg
|
||||
}
|
||||
}
|
||||
}
|
||||
case strings.EqualFold(key, "mcpServers"):
|
||||
servers, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -167,7 +189,7 @@ func stripNode(node any, scope string, required map[string]bool) {
|
||||
for serverName, server := range servers {
|
||||
stripNode(server, serverName, required)
|
||||
}
|
||||
case "env", "headers":
|
||||
case strings.EqualFold(key, "env"), strings.EqualFold(key, "headers"):
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestRereviewExportAcceptedFieldCasing(t *testing.T) {
|
||||
for _, upper := range []bool{false, true} {
|
||||
fields := map[string]any{"Env": map[string]string{"ORDINARY": "fixturesecret", "REF": "${EXISTING}"}, "Headers": map[string]string{"Authorization": "Basic fixturesecret"}, "Url": "https://host/mcp?%74oken=fixturesecret", "Args": []string{"-eKEY=fixturesecret", "-HAuthorization:Basic fixturesecret", "ordinary"}, "Command": "node --header 'Authorization: Basic fixturesecret'"}
|
||||
if upper {
|
||||
upperFields := make(map[string]any, len(fields))
|
||||
for key, value := range fields {
|
||||
upperFields[strings.ToUpper(key)] = value
|
||||
}
|
||||
fields = upperFields
|
||||
}
|
||||
root := testenv.TempDir(t)
|
||||
body, err := json.Marshal(map[string]any{"MCPServers": map[string]any{"neutral": fields}, "nested": map[string]any{"Runtime": fields}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeExportFile(t, filepath.Join(root, claudeMCPPath), string(body))
|
||||
manifest := &Manifest{}
|
||||
_, issues := appendClaudeMCPFile(root, manifest)
|
||||
if len(issues) != 0 || manifest.MCPServers["neutral"].URL != "https://host/mcp?%74oken=fixturesecret" {
|
||||
t.Fatalf("operational decoder rejected accepted casing: %+v %+v", manifest, issues)
|
||||
}
|
||||
archive, required, err := Export("neutral", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := exportedEntries(t, archive)["neutral/"+claudeMCPPath]
|
||||
if strings.Contains(out, "fixturesecret") {
|
||||
t.Errorf("export leaked: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "${EXISTING}") || !strings.Contains(out, "${NEUTRAL_AUTHORIZATION}") || !strings.Contains(out, "ordinary") || len(required) == 0 {
|
||||
t.Errorf("export reference/scope/ordinary contract changed: %s %v", out, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRereviewExportDecoderUnicodeCaseFolding(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
body := `{"mcp\u017ferver\u017f":{"neutral":{"url":"https://host/mcp","Header\u017f":{"Authorization":"Basic fixturesecret"},"Arg\u017f":["--token=fixturesecret"]}}}`
|
||||
writeExportFile(t, filepath.Join(root, claudeMCPPath), body)
|
||||
manifest := &Manifest{}
|
||||
_, issues := appendClaudeMCPFile(root, manifest)
|
||||
if len(issues) != 0 || manifest.MCPServers["neutral"].Headers["Authorization"] != "Basic fixturesecret" {
|
||||
t.Fatalf("decoder did not accept folded field: %+v %+v", manifest, issues)
|
||||
}
|
||||
archive, _, err := Export("neutral", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := exportedEntries(t, archive)["neutral/"+claudeMCPPath]
|
||||
if strings.Contains(out, "fixturesecret") || !strings.Contains(out, "${NEUTRAL_AUTHORIZATION}") {
|
||||
t.Fatalf("folded export field escaped projection: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
)
|
||||
|
||||
func (p Package) mcpServerRefs() []MCPServerRef {
|
||||
names := slices.Sorted(maps.Keys(p.Manifest.MCPServers))
|
||||
out := make([]MCPServerRef, 0, len(names))
|
||||
for _, name := range names {
|
||||
server := p.Manifest.MCPServers[name]
|
||||
out = append(out, MCPServerRef{
|
||||
Name: name,
|
||||
DisplayName: firstNonEmpty(strings.TrimSpace(server.DisplayName), name),
|
||||
Description: strings.TrimSpace(server.Description),
|
||||
Transport: pluginMCPTransport(server),
|
||||
Command: secrets.RedactConfigValue("", strings.TrimSpace(server.Command)),
|
||||
URL: secrets.RedactEndpoint(strings.TrimSpace(server.URL)),
|
||||
AutoStart: server.AutoStart == nil || *server.AutoStart,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -1144,24 +1143,6 @@ func (p Package) hookRefs() []HookRef {
|
||||
return out
|
||||
}
|
||||
|
||||
func (p Package) mcpServerRefs() []MCPServerRef {
|
||||
names := slices.Sorted(maps.Keys(p.Manifest.MCPServers))
|
||||
out := make([]MCPServerRef, 0, len(names))
|
||||
for _, name := range names {
|
||||
server := p.Manifest.MCPServers[name]
|
||||
out = append(out, MCPServerRef{
|
||||
Name: name,
|
||||
DisplayName: firstNonEmpty(strings.TrimSpace(server.DisplayName), name),
|
||||
Description: strings.TrimSpace(server.Description),
|
||||
Transport: pluginMCPTransport(server),
|
||||
Command: strings.TrimSpace(server.Command),
|
||||
URL: strings.TrimSpace(server.URL),
|
||||
AutoStart: server.AutoStart == nil || *server.AutoStart,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginMCPTransport(server MCPServer) string {
|
||||
if typ := strings.TrimSpace(server.Type); typ != "" {
|
||||
return typ
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestMCPCommandsDoNotPrintOperationalCredentials(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
workspace := testenv.TempDir(t)
|
||||
t.Chdir(workspace)
|
||||
entries := []config.PluginEntry{
|
||||
{Name: "neutral-http", Type: "http", URL: "https://host/mcp?%74oken=fixture-secret#passwd=fixture-secret", Headers: map[string]string{"Authorization": "fixture-secret"}},
|
||||
{Name: "neutral-stdio", Command: "node", Args: []string{"--password", "fixture-secret"}, Env: map[string]string{"PASSWORD": "fixture-secret", "author": "neutral"}},
|
||||
}
|
||||
servers := map[string]config.PluginEntry{}
|
||||
for _, entry := range entries {
|
||||
servers[entry.Name] = entry
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"mcpServers": servers})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".mcp.json"), raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := os.Stdout
|
||||
os.Stdout = writer
|
||||
t.Cleanup(func() { os.Stdout = previous; reader.Close(); writer.Close() })
|
||||
if mcpList() != 0 {
|
||||
t.Fatal("list failed")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if mcpGetCLI([]string{entry.Name}) != 0 {
|
||||
t.Fatal("get failed")
|
||||
}
|
||||
}
|
||||
writer.Close()
|
||||
os.Stdout = previous
|
||||
out, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "fixture-secret") {
|
||||
t.Fatalf("CLI leaked: %s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), "author=neutral") {
|
||||
t.Fatalf("ordinary env hidden: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/mcpregistry"
|
||||
"reasonix/internal/ext/mcpsetup"
|
||||
@@ -331,10 +332,10 @@ func mcpList() int {
|
||||
auto = " [auto_start=false]"
|
||||
}
|
||||
if typ == "stdio" {
|
||||
line := strings.TrimSpace(p.Command + " " + strings.Join(p.Args, " "))
|
||||
line := strings.TrimSpace(secrets.RedactConfigValue("", p.Command) + " " + strings.Join(secrets.RedactArgs(p.Args), " "))
|
||||
fmt.Printf("%-16s (stdio)%s %s\n", p.Name, auto, line)
|
||||
} else {
|
||||
fmt.Printf("%-16s (%s)%s %s\n", p.Name, typ, auto, p.URL)
|
||||
fmt.Printf("%-16s (%s)%s %s\n", p.Name, typ, auto, redactMCPURL(p.URL))
|
||||
}
|
||||
listed++
|
||||
}
|
||||
@@ -374,9 +375,9 @@ func printMCPEntry(p config.PluginEntry) {
|
||||
fmt.Printf("name: %s\n", p.Name)
|
||||
fmt.Printf("type: %s\n", typ)
|
||||
if typ == "stdio" {
|
||||
fmt.Printf("command: %s\n", p.Command)
|
||||
fmt.Printf("command: %s\n", secrets.RedactConfigValue("", p.Command))
|
||||
if len(p.Args) > 0 {
|
||||
fmt.Printf("args: %s\n", strings.Join(p.Args, "\n "))
|
||||
fmt.Printf("args: %s\n", strings.Join(secrets.RedactArgs(p.Args), "\n "))
|
||||
}
|
||||
if len(p.Env) > 0 {
|
||||
fmt.Println("env:")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/base/textutil"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/ext/mcpsetup"
|
||||
@@ -512,13 +513,18 @@ func (s *Server) mcpEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
// draftServer is one server a paste resolved to, in the shape the confirmation
|
||||
// card reads: what will run, what it will read, and what is risky about it.
|
||||
type draftServer struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
DisplayURL string `json:"displayUrl,omitempty"`
|
||||
DisplayEnv map[string]string `json:"displayEnv,omitempty"`
|
||||
DisplayHeaders map[string]string `json:"displayHeaders,omitempty"`
|
||||
DisplayCommand string `json:"displayCommand,omitempty"`
|
||||
DisplayArgs []string `json:"displayArgs,omitempty"`
|
||||
}
|
||||
|
||||
type draftRisk struct {
|
||||
@@ -549,6 +555,9 @@ func (s *Server) mcpParse(w http.ResponseWriter, r *http.Request) {
|
||||
servers = append(servers, draftServer{
|
||||
Name: e.Name, Transport: transportOf(e), Command: e.Command, Args: e.Args,
|
||||
URL: e.URL, Env: e.Env, Headers: e.Headers,
|
||||
DisplayURL: mcpsetup.RedactURL(e.URL),
|
||||
DisplayEnv: secrets.RedactConfigMap(e.Env), DisplayHeaders: secrets.RedactConfigMap(e.Headers),
|
||||
DisplayCommand: secrets.RedactConfigValue("", e.Command), DisplayArgs: secrets.RedactArgs(e.Args),
|
||||
})
|
||||
}
|
||||
risks := make([]draftRisk, 0, len(draft.Risks))
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestMCPPreviewSeparatesDisplayFromInstallValues(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(root)
|
||||
resolver := &provider.StaticResolver{Descriptors: []provider.Descriptor{{Ref: "neutral/fixture", Model: "fixture", Default: true}}, Providers: map[string]provider.Provider{"neutral/fixture": credentialPreviewProvider{}}}
|
||||
ctrl, err := boot.Build(t.Context(), boot.Options{Home: home, WorkspaceRoot: root, Model: "neutral/fixture", ProviderResolver: resolver, SessionDir: filepath.Join(root, "sessions"), TokenMode: boot.TokenModeFull, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
input := `{"mcpServers":{"neutral":{"url":"https://host/mcp?%74oken=fixture-secret","env":{"PASSWORD":"fixture-secret"},"headers":{"Authorization":"fixture-secret"}}}}`
|
||||
body, _ := json.Marshal(map[string]string{"input": input})
|
||||
w := httptest.NewRecorder()
|
||||
New(ctrl, NewBroadcaster(), config.ServeConfig{}).mcpParse(w, httptest.NewRequest(http.MethodPost, "/mcp/parse", strings.NewReader(string(body))))
|
||||
var response struct {
|
||||
Servers []map[string]any `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Servers) != 1 {
|
||||
t.Fatalf("response: %s", w.Body.String())
|
||||
}
|
||||
s := response.Servers[0]
|
||||
if !strings.Contains(s["url"].(string), "fixture-secret") {
|
||||
t.Fatal("install URL lost")
|
||||
}
|
||||
for _, key := range []string{"displayUrl", "displayEnv", "displayHeaders"} {
|
||||
value, ok := s[key]
|
||||
if !ok {
|
||||
t.Errorf("missing %s", key)
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(value)
|
||||
if strings.Contains(string(b), "fixture-secret") {
|
||||
t.Errorf("%s leaked", key)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp?client.token=fixturesecret#client.password=fixturesecret"}}}`,
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp?next=https%3A%2F%2Fu%3Afixturesecret%40host%2Fmcp"}}}`,
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp#redirect=https://u:fixturesecret@host/mcp"}}}`,
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp","env":{"ORDINARY":"https://host/mcp Bearer fixturesecret"}}}}`,
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp","env":{"ORDINARY":"https://host/mcp\rTOKEN=fixturesecret"}}}}`,
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp","env":{"ORDINARY":"https://host/mcp\fTOKEN=fixturesecret"}}}}`,
|
||||
"https://host/token/fixturesecret",
|
||||
"https://host/mcp#fixturesecret",
|
||||
"node --header 'Authorization: Bearer fixturesecret'",
|
||||
"node -e KEY=fixturesecret",
|
||||
"node -eKEY=fixturesecret",
|
||||
"node -HAuthorization:Basicfixturesecret",
|
||||
`{"mcpServers":{"neutral":{"url":"https://host/mcp","env":{"ORDINARY":"https://host/mcp Authorization: Basic fixturesecret"}}}}`,
|
||||
`{"mcpServers":{"neutral":{"command":"node --header 'Authorization: Bearer fixturesecret'"}}}`,
|
||||
"reasonix mcp add neutral --http https://host/mcp --header 'Authorization: Bearer fixturesecret'",
|
||||
} {
|
||||
body, _ := json.Marshal(map[string]string{"input": input})
|
||||
w := httptest.NewRecorder()
|
||||
New(ctrl, NewBroadcaster(), config.ServeConfig{}).mcpParse(w, httptest.NewRequest(http.MethodPost, "/mcp/parse", strings.NewReader(string(body))))
|
||||
if w.Code == 400 {
|
||||
if strings.Contains(w.Body.String(), "fixturesecret") {
|
||||
t.Fatalf("refusal leaked: %s", w.Body.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range wire["servers"].([]any) {
|
||||
server := row.(map[string]any)
|
||||
operational, _ := json.Marshal([]any{server["url"], server["command"], server["args"], server["env"], server["headers"]})
|
||||
if !strings.Contains(string(operational), "fixturesecret") {
|
||||
t.Fatal("operational credential lost")
|
||||
}
|
||||
for _, key := range []string{"url", "command", "args", "env", "headers"} {
|
||||
delete(server, key)
|
||||
}
|
||||
}
|
||||
display, _ := json.Marshal(wire)
|
||||
if strings.Contains(string(display), "fixturesecret") {
|
||||
t.Fatalf("preview leaked: %s", display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type credentialPreviewProvider struct{}
|
||||
|
||||
func (credentialPreviewProvider) Name() string { return "credential-preview" }
|
||||
func (credentialPreviewProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
|
||||
chunks := make(chan provider.Chunk)
|
||||
close(chunks)
|
||||
return chunks, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestRevisionParseRefusalMasksCredentials(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]string{"input": "reasonix mcp add neutral --http https://host/mcp --header 'Authorization: Bearer fixturesecret'"})
|
||||
w := httptest.NewRecorder()
|
||||
(&Server{}).mcpParse(w, httptest.NewRequest(http.MethodPost, "/mcp/parse", strings.NewReader(string(body))))
|
||||
if w.Code != 400 || strings.Contains(w.Body.String(), "fixturesecret") {
|
||||
t.Fatalf("refusal leaked: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionInstallRoundTripThroughBoot(t *testing.T) {
|
||||
root, home := testenv.TempDir(t), testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(root)
|
||||
resolver := &provider.StaticResolver{Descriptors: []provider.Descriptor{{Ref: "neutral/fixture", Model: "fixture", Default: true}}, Providers: map[string]provider.Provider{"neutral/fixture": credentialPreviewProvider{}}}
|
||||
ctrl, err := boot.Build(t.Context(), boot.Options{Home: home, WorkspaceRoot: root, Model: "neutral/fixture", ProviderResolver: resolver, SessionDir: filepath.Join(root, "sessions"), TokenMode: boot.TokenModeFull, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Basic fixturesecret" || r.URL.RawQuery != "%74oken=fixturesecret" || r.URL.Path != "/token/fixturesecret" {
|
||||
t.Error("operational request changed")
|
||||
}
|
||||
var req struct {
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if req.ID == nil {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
var result any = map[string]any{}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{"protocolVersion": "2025-06-18", "serverInfo": map[string]any{"name": "neutral", "version": "0"}, "capabilities": map[string]any{"tools": map[string]any{}}}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []any{}}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer remote.Close()
|
||||
endpoint := remote.URL + "/token/fixturesecret?%74oken=fixturesecret#fixturesecret"
|
||||
envValue := "https://host/mcp Authorization: Basic fixturesecret"
|
||||
input, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"neutral": map[string]any{"url": endpoint, "headers": map[string]string{"Authorization": "Basic fixturesecret"}, "env": map[string]string{"ORDINARY": envValue}}}})
|
||||
body, _ := json.Marshal(map[string]string{"input": string(input)})
|
||||
s := New(ctrl, NewBroadcaster(), config.ServeConfig{})
|
||||
w := httptest.NewRecorder()
|
||||
s.mcpParse(w, httptest.NewRequest(http.MethodPost, "/mcp/parse", strings.NewReader(string(body))))
|
||||
var preview struct {
|
||||
Servers []draftServer `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &preview); err != nil || len(preview.Servers) != 1 {
|
||||
t.Fatalf("parse: %s", w.Body.String())
|
||||
}
|
||||
for _, display := range []string{preview.Servers[0].DisplayURL, preview.Servers[0].DisplayEnv["ORDINARY"], preview.Servers[0].DisplayHeaders["Authorization"]} {
|
||||
if strings.Contains(display, "fixturesecret") {
|
||||
t.Fatal("preview credential leaked")
|
||||
}
|
||||
}
|
||||
body, _ = json.Marshal(map[string]any{"server": preview.Servers[0], "scope": "project"})
|
||||
w = httptest.NewRecorder()
|
||||
s.mcpInstall(w, httptest.NewRequest(http.MethodPost, "/mcp/install", strings.NewReader(string(body))))
|
||||
var installed struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &installed)
|
||||
if w.Code != 200 || installed.State != "ready" {
|
||||
t.Fatalf("install: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
cfg := config.LoadForEdit(filepath.Join(root, "reasonix.toml"))
|
||||
if len(cfg.Plugins) != 1 || cfg.Plugins[0].URL != endpoint || cfg.Plugins[0].Headers["Authorization"] != "Basic fixturesecret" || cfg.Plugins[0].Env["ORDINARY"] != envValue {
|
||||
t.Fatalf("persisted credentials changed: %+v", cfg.Plugins)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ func TestMCPParsePreviewsContinuedSetupCommands(t *testing.T) {
|
||||
input: "$ npx -y \\\n @reasonix/[email protected] \\\n --mode read",
|
||||
server: draftServer{
|
||||
Name: "fixture-server", Transport: "stdio", Command: "npx",
|
||||
Args: []string{"-y", "@reasonix/[email protected]", "--mode", "read"},
|
||||
Args: []string{"-y", "@reasonix/[email protected]", "--mode", "read"},
|
||||
DisplayCommand: "npx", DisplayArgs: []string{"-y", "@reasonix/[email protected]", "--mode", "read"},
|
||||
},
|
||||
risk: draftRisk{Server: "fixture-server", Kind: "shell", Field: "command", Detail: "npx -y @reasonix/[email protected] --mode read"},
|
||||
},
|
||||
@@ -36,7 +37,8 @@ func TestMCPParsePreviewsContinuedSetupCommands(t *testing.T) {
|
||||
input: "reasonix mcp add docs \\\n --http https://mcp.example.test/endpoint \\\n --header \"X-Label=word\\\nwrap\"",
|
||||
server: draftServer{
|
||||
Name: "docs", Transport: "http", URL: "https://mcp.example.test/endpoint",
|
||||
Headers: map[string]string{"X-Label": "wordwrap"},
|
||||
Headers: map[string]string{"X-Label": "wordwrap"},
|
||||
DisplayURL: "https://mcp.example.test/endpoint", DisplayHeaders: map[string]string{"X-Label": "wordwrap"},
|
||||
},
|
||||
risk: draftRisk{Server: "docs", Kind: "unknown-host", Field: "url", Detail: "https://mcp.example.test/endpoint"},
|
||||
},
|
||||
@@ -45,6 +47,7 @@ func TestMCPParsePreviewsContinuedSetupCommands(t *testing.T) {
|
||||
input: "server 'first\\\nsecond'",
|
||||
server: draftServer{
|
||||
Name: "server", Transport: "stdio", Command: "server", Args: []string{"first\\\nsecond"},
|
||||
DisplayCommand: "server", DisplayArgs: []string{"first\\\nsecond"},
|
||||
},
|
||||
risk: draftRisk{Server: "server", Kind: "shell", Field: "command", Detail: "server first\\\nsecond"},
|
||||
},
|
||||
|
||||
@@ -17,16 +17,17 @@ func TestMCPParseDockerNameUsesImageOperand(t *testing.T) {
|
||||
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
|
||||
t.Cleanup(srv.Close)
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
name string
|
||||
input string
|
||||
name string
|
||||
detail string
|
||||
}{
|
||||
{"docker run --rm -i mcp/time", "time"},
|
||||
{"docker run --rm -i mcp/fetch:latest", "fetch"},
|
||||
{"docker run -e MODE=test -v /data:/data mcp/filesystem /data", "filesystem"},
|
||||
{"docker run --network none registry.example:5000/tools/time:stable", "time"},
|
||||
{"docker container run --rm -i mcp/time@sha256:" + strings.Repeat("0123456789abcdef", 4), "time"},
|
||||
{"docker run --unknown option-value mcp/time", "mcp-server"},
|
||||
{"docker version", "mcp-server"},
|
||||
{"docker run --rm -i mcp/time", "time", ""},
|
||||
{"docker run --rm -i mcp/fetch:latest", "fetch", ""},
|
||||
{"docker run -e MODE=test -v /data:/data mcp/filesystem /data", "filesystem", "docker run -e <redacted> -v /data:/data mcp/filesystem /data"},
|
||||
{"docker run --network none registry.example:5000/tools/time:stable", "time", ""},
|
||||
{"docker container run --rm -i mcp/time@sha256:" + strings.Repeat("0123456789abcdef", 4), "time", ""},
|
||||
{"docker run --unknown option-value mcp/time", "mcp-server", ""},
|
||||
{"docker version", "mcp-server", ""},
|
||||
} {
|
||||
resp := postJSON(t, srv.URL+"/mcp/parse", map[string]string{"input": tc.input})
|
||||
var got struct {
|
||||
@@ -42,7 +43,11 @@ func TestMCPParseDockerNameUsesImageOperand(t *testing.T) {
|
||||
if entry.Name != tc.name || entry.Transport != "stdio" || entry.Command+" "+strings.Join(entry.Args, " ") != tc.input {
|
||||
t.Errorf("preview %q = %+v, want name %q and original argv", tc.input, entry, tc.name)
|
||||
}
|
||||
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != tc.input {
|
||||
detail := tc.detail
|
||||
if detail == "" {
|
||||
detail = tc.input
|
||||
}
|
||||
if len(got.Risks) != 1 || got.Risks[0].Server != tc.name || got.Risks[0].Kind != "shell" || got.Risks[0].Detail != detail {
|
||||
t.Errorf("preview disclosure = %+v", got.Risks)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user