feat(channels): apply context capabilities at runtime

This commit is contained in:
Goon committed 2026-05-29 19:35:02 +07:00
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 {
+10 -2
View File
@@ -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
View File
@@ -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
}
}
+6
View File
@@ -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,
+65 -2
View File
@@ -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
+41 -1
View File
@@ -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
+41 -1
View File
@@ -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`