From 993f3e0e337a7cac7297821edbb37b5ba7916eda Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:35:02 +0700 Subject: [PATCH] feat(channels): apply context capabilities at runtime --- .../agent/loop_pipeline_tool_callbacks.go | 18 +++++ internal/mcp/grant_checker.go | 12 ++- internal/mcp/manager.go | 55 +++++++++---- internal/store/channel_context_store.go | 6 ++ .../store/pg/channel_context_capabilities.go | 81 +++++++++++++++++++ internal/store/pg/mcp_servers_access.go | 67 ++++++++++++++- internal/store/pg/secure_cli.go | 42 +++++++++- .../channel-context-capabilities.go | 81 +++++++++++++++++++ .../store/sqlitestore/mcp_servers_access.go | 67 ++++++++++++++- internal/store/sqlitestore/secure-cli.go | 42 +++++++++- 10 files changed, 449 insertions(+), 22 deletions(-) diff --git a/internal/agent/loop_pipeline_tool_callbacks.go b/internal/agent/loop_pipeline_tool_callbacks.go index 765903df..5971ffc1 100644 --- a/internal/agent/loop_pipeline_tool_callbacks.go +++ b/internal/agent/loop_pipeline_tool_callbacks.go @@ -44,6 +44,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix: route through executeToolForActor so per-user MCP tools // resolve to the calling user's BridgeTool (not the first user's @@ -106,6 +107,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix (parallel path): route through executeToolForActor for per-user // MCP tool isolation. Same rationale as makeExecuteToolCall above. @@ -127,6 +129,22 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc } } +func channelContextScopeForRun(req *RunRequest) store.ChannelContextScope { + if req == nil || req.Channel == "" { + return store.ChannelContextScope{} + } + scope := store.ChannelContextScope{ + ChannelInstanceName: req.Channel, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: req.Channel, + } + if req.PeerKind == "group" && req.ChatID != "" { + scope.ScopeType = store.ChannelScopeTypeGroup + scope.ScopeKey = req.ChatID + } + return scope +} + // makeProcessToolResult wraps post-execution bookkeeping (sequential, mutates bridgeRS). // rawData is *toolRawResult from ExecuteToolRaw — no re-execution. func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message { diff --git a/internal/mcp/grant_checker.go b/internal/mcp/grant_checker.go index a144f463..a032b818 100644 --- a/internal/mcp/grant_checker.go +++ b/internal/mcp/grant_checker.go @@ -26,8 +26,11 @@ type GrantChecker interface { // cacheKey identifies a unique agent+user combination for caching. type cacheKey struct { - agentID uuid.UUID - userID string + agentID uuid.UUID + userID string + channelInstanceName string + scopeType string + scopeKey string } // cacheEntry holds the resolved access info for an agent+user. @@ -75,6 +78,11 @@ func (gc *storeGrantChecker) IsAllowed(ctx context.Context, agentID uuid.UUID, u } key := cacheKey{agentID: agentID, userID: userID} + if scope, ok := store.ChannelContextScopeFromContext(ctx); ok { + key.channelInstanceName = scope.ChannelInstanceName + key.scopeType = scope.ScopeType + key.scopeKey = scope.ScopeKey + } // Try cache first if cached, ok := gc.cache.Load(key); ok { diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 331a8d44..3208d75a 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -62,7 +62,7 @@ type connParams struct { type serverState struct { name string transport string - client *mcpclient.Client // direct ref for health checks (single-goroutine access) + client *mcpclient.Client // direct ref for health checks (single-goroutine access) clientPtr atomic.Pointer[mcpclient.Client] // shared atomic ref for BridgeTools (multi-goroutine safe) connected atomic.Bool toolNames []string // registered tool names in the registry @@ -70,10 +70,10 @@ type serverState struct { cancel context.CancelFunc conn connParams // connection params for reconnect - mu sync.Mutex - reconnAttempts int - healthFailures int // consecutive ping failures (resets on success) - lastErr string + mu sync.Mutex + reconnAttempts int + healthFailures int // consecutive ping failures (resets on success) + lastErr string // reconnPending is set when a BridgeTool detects the server reset its // session lifecycle (FastMCP-style "tools/call invalid during session @@ -111,13 +111,13 @@ type Manager struct { // Shared connection pool (nil = config-only mode) pool *Pool - poolServers map[string]struct{} // server names acquired from pool (for cleanup) - poolToolNames map[string][]string // per-agent tool names for pool-backed servers + poolServers map[string]struct{} // server names acquired from pool (for cleanup) + poolToolNames map[string][]string // per-agent tool names for pool-backed servers poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release // Search mode: deferred tools not registered in registry deferredTools map[string]*BridgeTool // registeredName → BridgeTool - activatedTools map[string]struct{} // tracks activated tool names for group:mcp + activatedTools map[string]struct{} // tracks activated tool names for group:mcp searchMode bool // User-credential servers: servers requiring per-user credentials, stored during @@ -224,13 +224,21 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc return nil } - // Skip server if it requires per-user credentials and user has none + var contextCreds *store.MCPContextCredentials + if contextStore, ok := m.store.(store.MCPContextAdminStore); ok { + if scope, hasScope := store.ChannelContextScopeFromContext(ctx); hasScope { + contextCreds, _ = contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID) + } + } + + // Skip server if it requires scoped/user credentials and none are present. if requireUserCreds(srv.Settings) { if userID == "" { return nil } uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID) - if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + hasContextCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if !hasContextCreds && (uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0)) { slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID) return nil } @@ -252,6 +260,27 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc headers["Authorization"] = "Bearer " + srv.APIKey } + if contextCreds != nil { + if contextCreds.APIKey != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + contextCreds.APIKey + } + for k, v := range contextCreds.Headers { + if headers == nil { + headers = make(map[string]string) + } + headers[k] = v + } + for k, v := range contextCreds.Env { + if env == nil { + env = make(map[string]string) + } + env[k] = v + } + } + // Merge per-user credentials (user overrides server defaults) if userID != "" && m.store != nil { if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil { @@ -278,12 +307,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc // Per-user credentials change connection params → can't share pool connection. // Fall back to per-agent mode when user has custom credentials. - hasUserCreds := userID != "" && m.store != nil - if hasUserCreds { + hasUserCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if userID != "" && m.store != nil { if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) { hasUserCreds = true - } else { - hasUserCreds = false } } diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go index c6775e9e..acfbb8cc 100644 --- a/internal/store/channel_context_store.go +++ b/internal/store/channel_context_store.go @@ -117,20 +117,26 @@ type MCPContextAdminStore interface { UpsertContextGrant(ctx context.Context, grant *MCPContextGrant) error DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextGrant, error) SetContextCredentials(ctx context.Context, creds *MCPContextCredentials) error GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*MCPContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, serverID uuid.UUID) (*MCPContextCredentials, error) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextCredentials, error) } type SecureCLIContextAdminStore interface { UpsertContextGrant(ctx context.Context, grant *SecureCLIContextGrant) error DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextGrant, error) SetContextCredentials(ctx context.Context, creds *SecureCLIContextCredentials) error GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextCredentials, error) } diff --git a/internal/store/pg/channel_context_capabilities.go b/internal/store/pg/channel_context_capabilities.go index 81f2015f..91fb3be4 100644 --- a/internal/store/pg/channel_context_capabilities.go +++ b/internal/store/pg/channel_context_capabilities.go @@ -14,6 +14,21 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = $1 AND name = $2`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + func validateChannelScope(scopeType, scopeKey string) error { switch scopeType { case store.ChannelScopeTypeChannel: @@ -109,6 +124,17 @@ func (s *PGMCPServerStore) ListContextGrants(ctx context.Context, channelInstanc return result, rows.Err() } +func (s *PGMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { return err @@ -170,6 +196,17 @@ func (s *PGMCPServerStore) GetContextCredentials(ctx context.Context, channelIns return creds, err } +func (s *PGMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + func (s *PGMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM mcp_context_credentials @@ -202,6 +239,17 @@ func (s *PGMCPServerStore) ListContextCredentials(ctx context.Context, channelIn return result, rows.Err() } +func (s *PGMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + type contextCredentialScanner interface { Scan(dest ...any) error } @@ -307,6 +355,17 @@ func (s *PGSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanc return result, rows.Err() } +func (s *PGSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { var g store.SecureCLIContextGrant var denyArgs, denyVerbose *[]byte @@ -386,6 +445,17 @@ func (s *PGSecureCLIStore) GetContextCredentials(ctx context.Context, channelIns return creds, err } +func (s *PGSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + func (s *PGSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM secure_cli_context_credentials @@ -419,6 +489,17 @@ func (s *PGSecureCLIStore) ListContextCredentials(ctx context.Context, channelIn return result, rows.Err() } +func (s *PGSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { var creds store.SecureCLIContextCredentials if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 9fd3f93e..b1b805a2 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -173,7 +173,11 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) } tClause, tArgs, _, err = scopeClauseAlias(ctx, 3, "ms") if err != nil { @@ -192,7 +196,66 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) +} + +func (s *PGMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok { + return result, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + return result, nil + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue + } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) + continue + } + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) + } + return result, nil +} + +func decodeGrantStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list } // scanAccessibleRows decodes the shared SELECT projection used by both the diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index b0b7eacf..22e79d3b 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -410,7 +410,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } // scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env. @@ -498,6 +502,42 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu return &b, nil } +func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok || b == nil { + return b, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + return nil, nil + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + break + } + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds != nil && len(creds.EncryptedEnv) > 0 { + b.EncryptedEnv = creds.EncryptedEnv + } + return b, nil +} + func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` var qArgs []any diff --git a/internal/store/sqlitestore/channel-context-capabilities.go b/internal/store/sqlitestore/channel-context-capabilities.go index c7f29acc..84efefb8 100644 --- a/internal/store/sqlitestore/channel-context-capabilities.go +++ b/internal/store/sqlitestore/channel-context-capabilities.go @@ -16,6 +16,21 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = ? AND name = ?`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + func validateChannelScope(scopeType, scopeKey string) error { switch scopeType { case store.ChannelScopeTypeChannel: @@ -112,6 +127,17 @@ func (s *SQLiteMCPServerStore) ListContextGrants(ctx context.Context, channelIns return result, rows.Err() } +func (s *SQLiteMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { return err @@ -173,6 +199,17 @@ func (s *SQLiteMCPServerStore) GetContextCredentials(ctx context.Context, channe return creds, err } +func (s *SQLiteMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + func (s *SQLiteMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM mcp_context_credentials @@ -205,6 +242,17 @@ func (s *SQLiteMCPServerStore) ListContextCredentials(ctx context.Context, chann return result, rows.Err() } +func (s *SQLiteMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + type contextCredentialScanner interface { Scan(dest ...any) error } @@ -313,6 +361,17 @@ func (s *SQLiteSecureCLIStore) ListContextGrants(ctx context.Context, channelIns return result, rows.Err() } +func (s *SQLiteSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { var g store.SecureCLIContextGrant var denyArgs, denyVerbose []byte @@ -395,6 +454,17 @@ func (s *SQLiteSecureCLIStore) GetContextCredentials(ctx context.Context, channe return creds, err } +func (s *SQLiteSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + func (s *SQLiteSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM secure_cli_context_credentials @@ -428,6 +498,17 @@ func (s *SQLiteSecureCLIStore) ListContextCredentials(ctx context.Context, chann return result, rows.Err() } +func (s *SQLiteSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { var creds store.SecureCLIContextCredentials var createdAt, updatedAt sqliteTime diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go index 01a169d2..01448f33 100644 --- a/internal/store/sqlitestore/mcp_servers_access.go +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -211,7 +211,11 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) } rows, err := s.db.QueryContext(ctx, `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, @@ -226,7 +230,66 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) +} + +func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok { + return result, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + return result, nil + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue + } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) + continue + } + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) + } + return result, nil +} + +func decodeGrantStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list } // scanAccessibleRows decodes the shared SELECT projection used by both the diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index 49084010..e669a71d 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -437,7 +437,11 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { @@ -524,6 +528,42 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. return &b, nil } +func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok || b == nil { + return b, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + return nil, nil + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + break + } + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds != nil && len(creds.EncryptedEnv) > 0 { + b.EncryptedEnv = creds.EncryptedEnv + } + return b, nil +} + // ListEnabled returns all enabled configs. func (s *SQLiteSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = 1`