| 1 | // Package mcplaunch stores exact project MCP launch authorizations and mutable |
| 2 | // launcher locks. It does not classify tools or contribute to provider-visible |
| 3 | // schemas; ordinary tool policy lives in the plugin and permission layers. |
| 4 | package mcplaunch |
| 5 | |
| 6 | import ( |
| 7 | "crypto/rand" |
| 8 | "crypto/sha256" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "runtime" |
| 16 | "sort" |
| 17 | "strconv" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | "time" |
| 21 | |
| 22 | "reasonix/internal/fileutil" |
| 23 | ) |
| 24 | |
| 25 | const ( |
| 26 | StoreVersion = 1 |
| 27 | StateFilename = "mcp-security.json" |
| 28 | workspaceScope = "workspace" |
| 29 | ) |
| 30 | |
| 31 | // ProjectLaunchIdentity is the secret-free canonical input to an exact project |
| 32 | // server launch identity digest. |
| 33 | // Environment and header values are intentionally excluded so credential |
| 34 | // rotation does not invalidate an otherwise identical authorization. |
| 35 | type ProjectLaunchIdentity struct { |
| 36 | Server string `json:"server"` |
| 37 | Transport string `json:"transport"` |
| 38 | CommandPath string `json:"command_path,omitempty"` |
| 39 | CommandSHA256 string `json:"command_sha256,omitempty"` |
| 40 | Args []string `json:"args,omitempty"` |
| 41 | Dir string `json:"dir,omitempty"` |
| 42 | URL string `json:"url,omitempty"` |
| 43 | EnvKeys []string `json:"env_keys,omitempty"` |
| 44 | HeaderKeys []string `json:"header_keys,omitempty"` |
| 45 | LauncherDigest string `json:"launcher_digest,omitempty"` |
| 46 | } |
| 47 | |
| 48 | type LauncherLock struct { |
| 49 | Server string `json:"server"` |
| 50 | Workspace string `json:"workspace_fingerprint,omitempty"` |
| 51 | Locator string `json:"locator"` |
| 52 | ResolvedVersion string `json:"resolved_version"` |
| 53 | ContentSHA256 string `json:"content_sha256"` |
| 54 | UpdatedAt time.Time `json:"updated_at"` |
| 55 | } |
| 56 | |
| 57 | // LaunchGrant is durable consent to start one exact project-provided MCP. |
| 58 | // Scope remains in the JSON solely so previous Reasonix versions can read new |
| 59 | // writes; new code only creates workspace-scoped grants. |
| 60 | type LaunchGrant struct { |
| 61 | Scope string `json:"scope"` |
| 62 | WorkspaceFingerprint string `json:"workspace_fingerprint,omitempty"` |
| 63 | Server string `json:"server"` |
| 64 | ConfigSource string `json:"config_source"` |
| 65 | IdentityDigest string `json:"identity_fingerprint"` |
| 66 | CreatedAt time.Time `json:"created_at"` |
| 67 | } |
| 68 | |
| 69 | // State stores server-level launch grants and exact mutable-launcher locks. |
| 70 | // Legacy per-tool reader receipts are deliberately not retained or consulted. |
| 71 | type State struct { |
| 72 | Version int `json:"version"` |
| 73 | LaunchGrants []LaunchGrant `json:"launch_grants,omitempty"` |
| 74 | LauncherLocks []LauncherLock `json:"launcher_locks,omitempty"` |
| 75 | LegacyImports json.RawMessage `json:"legacy_imports,omitempty"` |
| 76 | } |
| 77 | |
| 78 | type Manager struct { |
| 79 | path string |
| 80 | workspaceFingerprint string |
| 81 | mu sync.Mutex |
| 82 | } |
| 83 | |
| 84 | var managerRegistry struct { |
| 85 | sync.Mutex |
| 86 | items map[string]*Manager |
| 87 | } |
| 88 | |
| 89 | func StatePath(reasonixHome string) string { |
| 90 | if strings.TrimSpace(reasonixHome) == "" { |
| 91 | return "" |
| 92 | } |
| 93 | return filepath.Join(reasonixHome, StateFilename) |
| 94 | } |
| 95 | |
| 96 | func NewManager(path, workspace string) *Manager { |
| 97 | return &Manager{path: path, workspaceFingerprint: WorkspaceFingerprint(workspace)} |
| 98 | } |
| 99 | |
| 100 | // ForWorkspace returns the process-shared manager for one Reasonix home and |
| 101 | // workspace so sibling tabs observe one launch authorization state. |
| 102 | func ForWorkspace(reasonixHome, workspace string) *Manager { |
| 103 | path := StatePath(reasonixHome) |
| 104 | workspaceFP := WorkspaceFingerprint(workspace) |
| 105 | key := path + "\x00" + workspaceFP |
| 106 | managerRegistry.Lock() |
| 107 | defer managerRegistry.Unlock() |
| 108 | if managerRegistry.items == nil { |
| 109 | managerRegistry.items = map[string]*Manager{} |
| 110 | } |
| 111 | if m := managerRegistry.items[key]; m != nil { |
| 112 | return m |
| 113 | } |
| 114 | m := &Manager{path: path, workspaceFingerprint: workspaceFP} |
| 115 | managerRegistry.items[key] = m |
| 116 | return m |
| 117 | } |
| 118 | |
| 119 | func WorkspaceFingerprint(workspace string) string { |
| 120 | workspace = canonicalPath(workspace) |
| 121 | if workspace == "" { |
| 122 | return "" |
| 123 | } |
| 124 | return digestBytes([]byte(workspace)) |
| 125 | } |
| 126 | |
| 127 | func ProjectLaunchIdentityDigest(identity ProjectLaunchIdentity) (string, error) { |
| 128 | identity = normalizeIdentity(identity, runtime.GOOS == "windows") |
| 129 | // PackageDigest was never populated, but its empty field was part of the |
| 130 | // original canonical JSON. Keep that placeholder so existing project launch |
| 131 | // grants remain byte-for-byte valid after the internal cleanup. |
| 132 | payload := struct { |
| 133 | Server, Transport, CommandPath, CommandSHA256, Dir, URL string |
| 134 | Args, EnvKeys, HeaderKeys []string |
| 135 | PackageDigest, LauncherDigest string |
| 136 | }{ |
| 137 | identity.Server, identity.Transport, identity.CommandPath, identity.CommandSHA256, |
| 138 | identity.Dir, identity.URL, identity.Args, identity.EnvKeys, identity.HeaderKeys, |
| 139 | "", identity.LauncherDigest, |
| 140 | } |
| 141 | body, err := json.Marshal(payload) |
| 142 | if err != nil { |
| 143 | return "", err |
| 144 | } |
| 145 | return digestBytes(body), nil |
| 146 | } |
| 147 | |
| 148 | func normalizeIdentity(identity ProjectLaunchIdentity, envCaseInsensitive bool) ProjectLaunchIdentity { |
| 149 | identity.Server = strings.TrimSpace(identity.Server) |
| 150 | identity.Transport = normalizeTransport(identity.Transport) |
| 151 | identity.CommandPath = canonicalPath(identity.CommandPath) |
| 152 | identity.Dir = canonicalPath(identity.Dir) |
| 153 | identity.URL = strings.TrimSpace(identity.URL) |
| 154 | identity.Args = append([]string(nil), identity.Args...) |
| 155 | identity.EnvKeys = cleanStrings(identity.EnvKeys, envCaseInsensitive) |
| 156 | identity.HeaderKeys = cleanStrings(identity.HeaderKeys, true) |
| 157 | return identity |
| 158 | } |
| 159 | |
| 160 | func FileSHA256(path string) (string, error) { |
| 161 | body, err := os.ReadFile(path) |
| 162 | if err != nil { |
| 163 | return "", err |
| 164 | } |
| 165 | return digestBytes(body), nil |
| 166 | } |
| 167 | |
| 168 | func (m *Manager) Path() string { return m.path } |
| 169 | |
| 170 | func (m *Manager) WorkspaceFingerprint() string { return m.workspaceFingerprint } |
| 171 | |
| 172 | func (m *Manager) Load() (State, error) { |
| 173 | if strings.TrimSpace(m.path) == "" { |
| 174 | return State{Version: StoreVersion}, nil |
| 175 | } |
| 176 | body, err := os.ReadFile(m.path) |
| 177 | if err != nil { |
| 178 | if errors.Is(err, os.ErrNotExist) { |
| 179 | return State{Version: StoreVersion}, nil |
| 180 | } |
| 181 | return State{}, err |
| 182 | } |
| 183 | var state State |
| 184 | if err := json.Unmarshal(body, &state); err != nil { |
| 185 | return State{}, fmt.Errorf("parse MCP launch authorization state: %w", err) |
| 186 | } |
| 187 | if state.Version == 0 { |
| 188 | state.Version = StoreVersion |
| 189 | } |
| 190 | if state.Version != StoreVersion { |
| 191 | return State{}, fmt.Errorf("unsupported MCP launch authorization state version %d", state.Version) |
| 192 | } |
| 193 | normalizeState(&state) |
| 194 | return state, nil |
| 195 | } |
| 196 | |
| 197 | // Authorize records durable workspace consent for one exact server identity. |
| 198 | func (m *Manager) Authorize(server, configSource, identityDigest string) error { |
| 199 | grant := LaunchGrant{ |
| 200 | Scope: workspaceScope, WorkspaceFingerprint: m.workspaceFingerprint, |
| 201 | Server: strings.TrimSpace(server), ConfigSource: strings.TrimSpace(configSource), |
| 202 | IdentityDigest: strings.TrimSpace(identityDigest), CreatedAt: time.Now().UTC(), |
| 203 | } |
| 204 | if grant.Server == "" || grant.ConfigSource == "" || grant.IdentityDigest == "" { |
| 205 | return fmt.Errorf("MCP launch authorization requires server, config source, and identity") |
| 206 | } |
| 207 | m.mu.Lock() |
| 208 | defer m.mu.Unlock() |
| 209 | return m.updatePersistent(func(state *State) { |
| 210 | state.LaunchGrants = upsertLaunchGrant(state.LaunchGrants, grant) |
| 211 | }) |
| 212 | } |
| 213 | |
| 214 | // LaunchAuthorized checks exact server-level consent without starting the server. |
| 215 | func (m *Manager) LaunchAuthorized(server, configSource, identityDigest string) (authorized, changed bool, err error) { |
| 216 | server = strings.TrimSpace(server) |
| 217 | configSource = strings.TrimSpace(configSource) |
| 218 | identityDigest = strings.TrimSpace(identityDigest) |
| 219 | m.mu.Lock() |
| 220 | defer m.mu.Unlock() |
| 221 | state, err := m.Load() |
| 222 | if err != nil { |
| 223 | return false, false, err |
| 224 | } |
| 225 | for _, grant := range state.LaunchGrants { |
| 226 | if grant.Server != server || grant.ConfigSource != configSource || grant.WorkspaceFingerprint != m.workspaceFingerprint { |
| 227 | continue |
| 228 | } |
| 229 | if grant.IdentityDigest == identityDigest { |
| 230 | return true, false, nil |
| 231 | } |
| 232 | changed = true |
| 233 | } |
| 234 | return false, changed, nil |
| 235 | } |
| 236 | |
| 237 | func (m *Manager) Revoke(server string) error { |
| 238 | server = strings.TrimSpace(server) |
| 239 | m.mu.Lock() |
| 240 | defer m.mu.Unlock() |
| 241 | return m.updatePersistent(func(state *State) { |
| 242 | out := state.LaunchGrants[:0] |
| 243 | for _, grant := range state.LaunchGrants { |
| 244 | if grant.Server == server && grant.WorkspaceFingerprint == m.workspaceFingerprint { |
| 245 | continue |
| 246 | } |
| 247 | out = append(out, grant) |
| 248 | } |
| 249 | state.LaunchGrants = out |
| 250 | }) |
| 251 | } |
| 252 | |
| 253 | func (m *Manager) GetLauncherLock(server, locator string) (LauncherLock, bool, error) { |
| 254 | m.mu.Lock() |
| 255 | defer m.mu.Unlock() |
| 256 | state, err := m.Load() |
| 257 | if err != nil { |
| 258 | return LauncherLock{}, false, err |
| 259 | } |
| 260 | for _, lock := range state.LauncherLocks { |
| 261 | if lock.Server == strings.TrimSpace(server) && lock.Locator == strings.TrimSpace(locator) && lock.Workspace == m.workspaceFingerprint { |
| 262 | return lock, true, nil |
| 263 | } |
| 264 | } |
| 265 | return LauncherLock{}, false, nil |
| 266 | } |
| 267 | |
| 268 | func (m *Manager) PutLauncherLock(lock LauncherLock) error { |
| 269 | lock.Server = strings.TrimSpace(lock.Server) |
| 270 | lock.Locator = strings.TrimSpace(lock.Locator) |
| 271 | lock.ResolvedVersion = strings.TrimSpace(lock.ResolvedVersion) |
| 272 | lock.ContentSHA256 = strings.TrimSpace(lock.ContentSHA256) |
| 273 | if lock.Server == "" || lock.Locator == "" || lock.ResolvedVersion == "" || lock.ContentSHA256 == "" { |
| 274 | return fmt.Errorf("incomplete MCP launcher lock") |
| 275 | } |
| 276 | lock.Workspace = m.workspaceFingerprint |
| 277 | lock.UpdatedAt = time.Now().UTC() |
| 278 | m.mu.Lock() |
| 279 | defer m.mu.Unlock() |
| 280 | return m.updatePersistent(func(state *State) { |
| 281 | for i := range state.LauncherLocks { |
| 282 | if state.LauncherLocks[i].Server == lock.Server && state.LauncherLocks[i].Locator == lock.Locator && state.LauncherLocks[i].Workspace == lock.Workspace { |
| 283 | state.LauncherLocks[i] = lock |
| 284 | return |
| 285 | } |
| 286 | } |
| 287 | state.LauncherLocks = append(state.LauncherLocks, lock) |
| 288 | }) |
| 289 | } |
| 290 | |
| 291 | func LauncherLockFingerprint(lock LauncherLock) string { |
| 292 | payload := struct { |
| 293 | Server, Workspace, Locator, ResolvedVersion, ContentSHA256 string |
| 294 | }{lock.Server, lock.Workspace, lock.Locator, lock.ResolvedVersion, lock.ContentSHA256} |
| 295 | body, _ := json.Marshal(payload) |
| 296 | return digestBytes(body) |
| 297 | } |
| 298 | |
| 299 | func (m *Manager) updatePersistent(update func(*State)) error { |
| 300 | if strings.TrimSpace(m.path) == "" { |
| 301 | return fmt.Errorf("MCP launch authorization state path is unavailable") |
| 302 | } |
| 303 | unlock, err := acquireFileLock(m.path+".lock", 2*time.Second) |
| 304 | if err != nil { |
| 305 | return err |
| 306 | } |
| 307 | defer unlock() |
| 308 | state, err := m.Load() |
| 309 | if err != nil { |
| 310 | return err |
| 311 | } |
| 312 | update(&state) |
| 313 | state.Version = StoreVersion |
| 314 | normalizeState(&state) |
| 315 | body, err := json.MarshalIndent(state, "", " ") |
| 316 | if err != nil { |
| 317 | return err |
| 318 | } |
| 319 | body = append(body, '\n') |
| 320 | return fileutil.AtomicWriteFile(m.path, body, 0o600) |
| 321 | } |
| 322 | |
| 323 | func normalizeState(state *State) { |
| 324 | if state.Version == 0 { |
| 325 | state.Version = StoreVersion |
| 326 | } |
| 327 | state.LaunchGrants = dedupeLaunchGrants(state.LaunchGrants) |
| 328 | sort.Slice(state.LauncherLocks, func(i, j int) bool { |
| 329 | a, b := state.LauncherLocks[i], state.LauncherLocks[j] |
| 330 | if a.Server != b.Server { |
| 331 | return a.Server < b.Server |
| 332 | } |
| 333 | return a.Locator < b.Locator |
| 334 | }) |
| 335 | sort.Slice(state.LaunchGrants, func(i, j int) bool { |
| 336 | a, b := state.LaunchGrants[i], state.LaunchGrants[j] |
| 337 | if a.Server != b.Server { |
| 338 | return a.Server < b.Server |
| 339 | } |
| 340 | return a.ConfigSource < b.ConfigSource |
| 341 | }) |
| 342 | } |
| 343 | |
| 344 | func upsertLaunchGrant(grants []LaunchGrant, grant LaunchGrant) []LaunchGrant { |
| 345 | for i := range grants { |
| 346 | if grants[i].WorkspaceFingerprint == grant.WorkspaceFingerprint && grants[i].Server == grant.Server && grants[i].ConfigSource == grant.ConfigSource { |
| 347 | grant.CreatedAt = grants[i].CreatedAt |
| 348 | grants[i] = grant |
| 349 | return grants |
| 350 | } |
| 351 | } |
| 352 | return append(grants, grant) |
| 353 | } |
| 354 | |
| 355 | func dedupeLaunchGrants(grants []LaunchGrant) []LaunchGrant { |
| 356 | out := make([]LaunchGrant, 0, len(grants)) |
| 357 | for _, grant := range grants { |
| 358 | out = upsertLaunchGrant(out, grant) |
| 359 | } |
| 360 | return out |
| 361 | } |
| 362 | |
| 363 | func normalizeTransport(value string) string { |
| 364 | switch strings.ToLower(strings.TrimSpace(value)) { |
| 365 | case "", "stdio": |
| 366 | return "stdio" |
| 367 | case "http", "streamable-http", "streamable_http": |
| 368 | return "http" |
| 369 | default: |
| 370 | return strings.ToLower(strings.TrimSpace(value)) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | func canonicalPath(path string) string { |
| 375 | path = strings.TrimSpace(path) |
| 376 | if path == "" { |
| 377 | return "" |
| 378 | } |
| 379 | if abs, err := filepath.Abs(path); err == nil { |
| 380 | path = abs |
| 381 | } |
| 382 | if real, err := filepath.EvalSymlinks(path); err == nil { |
| 383 | path = real |
| 384 | } |
| 385 | return filepath.Clean(path) |
| 386 | } |
| 387 | |
| 388 | func cleanStrings(values []string, fold bool) []string { |
| 389 | out := make([]string, 0, len(values)) |
| 390 | for _, value := range values { |
| 391 | value = strings.TrimSpace(value) |
| 392 | if value == "" { |
| 393 | continue |
| 394 | } |
| 395 | if fold { |
| 396 | value = strings.ToLower(value) |
| 397 | } |
| 398 | out = append(out, value) |
| 399 | } |
| 400 | sort.Strings(out) |
| 401 | return compactStrings(out) |
| 402 | } |
| 403 | |
| 404 | func compactStrings(values []string) []string { |
| 405 | if len(values) < 2 { |
| 406 | return values |
| 407 | } |
| 408 | out := values[:1] |
| 409 | for _, value := range values[1:] { |
| 410 | if value != out[len(out)-1] { |
| 411 | out = append(out, value) |
| 412 | } |
| 413 | } |
| 414 | return out |
| 415 | } |
| 416 | |
| 417 | func digestBytes(body []byte) string { |
| 418 | sum := sha256.Sum256(body) |
| 419 | return hex.EncodeToString(sum[:]) |
| 420 | } |
| 421 | |
| 422 | func acquireFileLock(path string, wait time.Duration) (func(), error) { |
| 423 | token := make([]byte, 16) |
| 424 | if _, err := rand.Read(token); err != nil { |
| 425 | return nil, fmt.Errorf("generate MCP authorization lock owner: %w", err) |
| 426 | } |
| 427 | owner := []byte(fmt.Sprintf("%d %s\n", os.Getpid(), hex.EncodeToString(token))) |
| 428 | deadline := time.Now().Add(wait) |
| 429 | for { |
| 430 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| 431 | return nil, err |
| 432 | } |
| 433 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 434 | if err == nil { |
| 435 | _, writeErr := f.Write(owner) |
| 436 | closeErr := f.Close() |
| 437 | if writeErr != nil || closeErr != nil { |
| 438 | _ = os.Remove(path) |
| 439 | if writeErr != nil { |
| 440 | return nil, writeErr |
| 441 | } |
| 442 | return nil, closeErr |
| 443 | } |
| 444 | return func() { removeOwnedFileLock(path, owner) }, nil |
| 445 | } |
| 446 | if !errors.Is(err, os.ErrExist) && !launchLockContention(err) { |
| 447 | return nil, err |
| 448 | } |
| 449 | if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { |
| 450 | current, readErr := os.ReadFile(path) |
| 451 | if readErr == nil && !fileLockOwnerAlive(current) { |
| 452 | removeOwnedFileLock(path, current) |
| 453 | continue |
| 454 | } |
| 455 | } |
| 456 | if time.Now().After(deadline) { |
| 457 | return nil, fmt.Errorf("timed out waiting for MCP authorization state lock") |
| 458 | } |
| 459 | time.Sleep(10 * time.Millisecond) |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | func fileLockOwnerAlive(owner []byte) bool { |
| 464 | fields := strings.Fields(string(owner)) |
| 465 | if len(fields) == 0 { |
| 466 | return false |
| 467 | } |
| 468 | pid, err := strconv.Atoi(fields[0]) |
| 469 | return err == nil && launchLockProcessAlive(pid) |
| 470 | } |
| 471 | |
| 472 | func removeOwnedFileLock(path string, owner []byte) { |
| 473 | current, err := os.ReadFile(path) |
| 474 | if err != nil || string(current) != string(owner) { |
| 475 | return |
| 476 | } |
| 477 | _ = os.Remove(path) |
| 478 | } |
| 479 |