mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
feat(channels): apply context capabilities at runtime
This commit is contained in:
1 parent
bd34e2917e
commit
993f3e0e33
10 files changed
+449
-22
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+41
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user