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:
esengine committed 2026-10-03 19:19:58 -07:00
1 parent 7aeae1990d
commit 0da518ce84
79 files changed
+2435 -282

No files matched your search

+5
View File
@@ -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,
+1 -1
View File
@@ -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");
});
+59
View File
@@ -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.
+12
View File
@@ -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:
+46
View File
@@ -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))
}
+259
View File
@@ -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)
}
}
+64
View File
@@ -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)
}
}
+12 -19
View File
@@ -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 {
+52
View File
@@ -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)
}
}
}
+176
View File
@@ -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
}
+99
View File
@@ -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)
}
}
}
}
+5 -2
View File
@@ -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)
}
+2
View File
@@ -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)
}
}
}
+10 -7
View File
@@ -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)
+22 -8
View File
@@ -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)
}
}
}
+63 -7
View File
@@ -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() }
+10 -13
View File
@@ -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",
}
}
+3 -2
View File
@@ -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")
+2 -2
View File
@@ -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
}
+7 -7
View File
@@ -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
}
+24
View File
@@ -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)
}
+10 -3
View File
@@ -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)
}
}
+6 -6
View File
@@ -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)
}
}
}
+3 -2
View File
@@ -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)})
+6 -50
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
}
}
+71
View File
@@ -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
}
+2
View File
@@ -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)
+2 -2
View File
@@ -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{}
+6
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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}
}
+3 -4
View File
@@ -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 {
+2 -2
View File
@@ -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
+13 -23
View File
@@ -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 {
+4 -4
View File
@@ -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)
}
}
+18
View File
@@ -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() }
+17 -7
View File
@@ -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
}
+17 -18
View File
@@ -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 -1
View File
@@ -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)
}
}
+3 -3
View File
@@ -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 -9
View File
@@ -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
+10 -15
View File
@@ -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)
}
}
}
+25 -3
View File
@@ -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)
}
}
+27
View File
@@ -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
}
-19
View File
@@ -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)
}
}
+5 -4
View File
@@ -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:")
+16 -7
View File
@@ -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)
}
}