返回 DeepSeek-Reasonix
security.go
根目录 / internal / plugin / security.go
1 package plugin
2
3 import (
4 "context"
5 "fmt"
6 "net"
7 "net/url"
8 "path/filepath"
9 "sort"
10 "strings"
11
12 "reasonix/internal/mcplaunch"
13 "reasonix/internal/secrets"
14 "reasonix/internal/tool"
15 )
16
17 // projectLaunchIdentityDigest resolves a secret-free identity before a project
18 // server is authorized. For stdio this pins the real executable path and file
19 // content; for HTTP it normalizes the endpoint while retaining only header key
20 // names. Installed and host-session servers never call this path.
21 func projectLaunchIdentityDigest(ctx context.Context, s Spec) (string, error) {
22 identity, err := buildProjectLaunchIdentity(ctx, s)
23 if err != nil {
24 return "", err
25 }
26 return mcplaunch.ProjectLaunchIdentityDigest(identity)
27 }
28
29 func buildProjectLaunchIdentity(ctx context.Context, s Spec) (mcplaunch.ProjectLaunchIdentity, error) {
30 transport := strings.ToLower(strings.TrimSpace(s.Type))
31 if transport == "" {
32 transport = "stdio"
33 }
34 launchArgs := effectiveLaunchArgs(s)
35 if s.LauncherIdentityArgs != nil {
36 launchArgs = s.LauncherIdentityArgs
37 }
38 identity := mcplaunch.ProjectLaunchIdentity{
39 Server: s.Name, Transport: transport,
40 Dir: s.Dir, Args: append([]string(nil), launchArgs...),
41 EnvKeys: sortedMapKeys(s.Env), HeaderKeys: sortedMapKeys(s.Headers),
42 LauncherDigest: s.LauncherDigest,
43 }
44 switch transport {
45 case "stdio":
46 identity.Dir = stdioWorkingDir(s)
47 if strings.TrimSpace(s.Command) == "" {
48 return mcplaunch.ProjectLaunchIdentity{}, fmt.Errorf("stdio plugin %q: command is required", s.Name)
49 }
50 env := mergeEnv(secrets.ProcessEnv(), s.Env)
51 exe, _, err := resolveStdioExecutable(ctx, s, env)
52 if err != nil {
53 return mcplaunch.ProjectLaunchIdentity{}, err
54 }
55 identity.CommandPath = exe
56 identity.CommandSHA256, err = mcplaunch.FileSHA256(exe)
57 if err != nil {
58 return mcplaunch.ProjectLaunchIdentity{}, fmt.Errorf("hash MCP executable %q: %w", exe, err)
59 }
60 case "http", "streamable-http", "streamable_http":
61 identity.Transport = "http"
62 identity.URL = normalizeIdentityURL(s.URL)
63 default:
64 identity.URL = normalizeIdentityURL(s.URL)
65 }
66 return identity, nil
67 }
68
69 // MCPStateDir returns a stable, server-scoped host directory outside the
70 // workspace for state that must survive across calls and sessions.
71 func MCPStateDir(reasonixHome, workspace, server string) string {
72 if strings.TrimSpace(reasonixHome) == "" {
73 return ""
74 }
75 workspaceID := mcplaunch.WorkspaceFingerprint(workspace)
76 if len(workspaceID) > 16 {
77 workspaceID = workspaceID[:16]
78 }
79 if workspaceID == "" {
80 workspaceID = "global"
81 }
82 return filepath.Join(reasonixHome, "mcp-state", workspaceID, slug(server))
83 }
84
85 // identityURLRedacted replaces credential material inside identity and cache
86 // URLs. Only the structure survives: whether userinfo/a password exists and
87 // how many values a credential parameter carries, never their contents.
88 const identityURLRedacted = "__redacted__"
89
90 // credentialURLQueryKeys lists query parameters whose values are credentials.
91 // Keys are compared case-insensitively after removing "-" and "_", so
92 // api_key, api-key, x-api-key, and APIKEY normalize consistently, and any
93 // normalized key ending in a credentialURLQuerySuffixes entry (auth_token,
94 // refresh_token, id_token, client_secret, sas_signature, ...) is a credential
95 // too. Non-sensitive parameters (workspace, tenant, region, resource, ...)
96 // keep their values so a resource scope change still re-triggers verification.
97 var credentialURLQueryKeys = map[string]bool{
98 "auth": true, "authorization": true, "bearer": true, "credential": true,
99 "credentials": true, "sig": true,
100 // The key family stays an exact list: a bare "*key" suffix would also
101 // swallow unrelated words (monkey, sortkey-like resource names).
102 "key": true, "accesskey": true, "secretkey": true, "privatekey": true,
103 "authkey": true, "appkey": true, "clientkey": true, "subscriptionkey": true,
104 "sharedkey": true,
105 }
106
107 // credentialURLQuerySuffixes classifies whole credential families by suffix:
108 // every *token, *secret, *password/*passwd, *apikey, and *signature parameter
109 // carries a credential value regardless of its prefix.
110 var credentialURLQuerySuffixes = []string{
111 "token", "secret", "password", "passwd", "apikey", "signature",
112 }
113
114 func credentialURLQueryKey(key string) bool {
115 normalized := strings.NewReplacer("-", "", "_", "").Replace(strings.ToLower(strings.TrimSpace(key)))
116 if credentialURLQueryKeys[normalized] {
117 return true
118 }
119 for _, suffix := range credentialURLQuerySuffixes {
120 if strings.HasSuffix(normalized, suffix) {
121 return true
122 }
123 }
124 return false
125 }
126
127 // normalizeIdentityURL canonicalizes an MCP endpoint for host-local identity
128 // and schema-cache keys: scheme/host case and default ports fold,
129 // the fragment drops, query keys sort stably, and credential material
130 // (userinfo, credential query values) is replaced by a fixed placeholder so
131 // rotation never invalidates an exact project launch authorization.
132 // Network requests always use the raw configured URL, never this form.
133 func normalizeIdentityURL(raw string) string {
134 u, err := url.Parse(strings.TrimSpace(raw))
135 if err != nil || u.Scheme == "" || u.Host == "" {
136 return strings.TrimSpace(raw)
137 }
138 u.Scheme = strings.ToLower(u.Scheme)
139 host := strings.ToLower(u.Hostname())
140 port := u.Port()
141 if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
142 port = ""
143 }
144 if strings.Contains(host, ":") {
145 host = "[" + host + "]"
146 }
147 if port != "" {
148 host = net.JoinHostPort(strings.Trim(host, "[]"), port)
149 }
150 u.Host = host
151 u.Fragment = ""
152 if u.User != nil {
153 if _, hasPassword := u.User.Password(); hasPassword {
154 u.User = url.UserPassword(identityURLRedacted, identityURLRedacted)
155 } else {
156 u.User = url.User(identityURLRedacted)
157 }
158 }
159 if u.RawQuery != "" {
160 query := u.Query()
161 for key, values := range query {
162 if credentialURLQueryKey(key) {
163 for i := range values {
164 values[i] = identityURLRedacted
165 }
166 } else {
167 sort.Strings(values)
168 }
169 query[key] = values
170 }
171 // Encode sorts keys, so equivalent URLs cannot differ by parameter order.
172 u.RawQuery = query.Encode()
173 }
174 return u.String()
175 }
176
177 // legacyNormalizeIdentityURL is the pre-credential-aware normalization kept
178 // only so old schema-cache keys remain readable during the compatibility
179 // window. It no longer participates in authorization or tool classification.
180 func legacyNormalizeIdentityURL(raw string) string {
181 u, err := url.Parse(strings.TrimSpace(raw))
182 if err != nil || u.Scheme == "" || u.Host == "" {
183 return strings.TrimSpace(raw)
184 }
185 u.Scheme = strings.ToLower(u.Scheme)
186 host := strings.ToLower(u.Hostname())
187 port := u.Port()
188 if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
189 port = ""
190 }
191 if strings.Contains(host, ":") {
192 host = "[" + host + "]"
193 }
194 if port != "" {
195 host = net.JoinHostPort(strings.Trim(host, "[]"), port)
196 }
197 u.Host = host
198 u.Fragment = ""
199 return u.String()
200 }
201
202 func sortedMapKeys[V any](values map[string]V) []string {
203 out := make([]string, 0, len(values))
204 for key := range values {
205 if key = strings.TrimSpace(key); key != "" {
206 out = append(out, key)
207 }
208 }
209 sort.Strings(out)
210 return out
211 }
212
213 func launchConfigSource(s Spec) string {
214 return s.ConfigSource
215 }
216
217 // CachedToolSafety is the local safety classification for one tool in an
218 // identity-matched schema cache. Authorization belongs to the MCP server;
219 // cached tools retain only safety facts that must match the live server.
220 type CachedToolSafety struct {
221 ReadOnly bool
222 Destructive bool
223 }
224
225 // LiveToolSafety returns the host-local execution classification for a live MCP
226 // target. remoteTool uses one locked snapshot; compatibility adapters fall back
227 // to the public tool interfaces. The result never changes provider-visible
228 // schemas.
229 func LiveToolSafety(target tool.Tool) CachedToolSafety {
230 if target == nil {
231 return CachedToolSafety{}
232 }
233 if remote, ok := target.(*remoteTool); ok {
234 _, readOnly, destructive := remote.securitySnapshot()
235 return CachedToolSafety{
236 ReadOnly: readOnly,
237 Destructive: destructive,
238 }
239 }
240 safety := CachedToolSafety{ReadOnly: target.ReadOnly()}
241 if annotations, ok := target.(tool.MCPAnnotations); ok {
242 safety.Destructive = annotations.MCPDestructiveHint()
243 }
244 return safety
245 }
246
247 // ReconcileCachedToolSafety is the single cached-to-live boundary shared by
248 // lazy and on-demand MCP adapters. Both adapters stop the current call when the
249 // live server is stricter than the snapshot, before the direct tool executes.
250 func ReconcileCachedToolSafety(server, rawName string, cached CachedToolSafety, target tool.Tool) (CachedToolSafety, error) {
251 live := LiveToolSafety(target)
252 if target == nil {
253 return live, nil
254 }
255 if cached.ReadOnly && !live.ReadOnly {
256 return live, fmt.Errorf("MCP server %q no longer marks tool %q as read-only; the current call was blocked before execution — retry so Reasonix can apply the current Plan/read-only safety boundary", server, rawName)
257 }
258 if !cached.Destructive && live.Destructive {
259 return live, fmt.Errorf("MCP server %q now marks tool %q as destructive; retry so Reasonix can apply the current Plan/read-only safety boundary before execution", server, rawName)
260 }
261 return live, nil
262 }
263
264 func CachedToolSafetyForSpec(s Spec, rawName string) (CachedToolSafety, bool) {
265 cs, ok := LoadCachedSchemaForSpec(s)
266 if !ok {
267 return CachedToolSafety{}, false
268 }
269 var target *CachedToolSafety
270 for _, cached := range cs.Tools {
271 if cached.Name == rawName {
272 copy := CachedToolSafety{ReadOnly: cached.ReadOnly, Destructive: cached.Destructive}
273 target = &copy
274 break
275 }
276 }
277 if target == nil {
278 return CachedToolSafety{}, false
279 }
280 return *target, true
281 }
282
282 lines GO