Files
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

346 lines
11 KiB
Go

package providers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// MCPServerEntry represents a single MCP server config for CLI injection.
type MCPServerEntry struct {
Name string
Transport string // "stdio", "sse", "streamable-http"
Command string
Args []string
URL string
Headers map[string]string
Env map[string]string
}
// MCPServerLookup returns accessible MCP servers for a given agent ID.
// Used to inject per-agent DB-backed MCP servers into CLI MCP config.
type MCPServerLookup func(ctx context.Context, agentID string) []MCPServerEntry
// MCPConfigData holds the base MCP server entries built at startup.
// Per-session configs are written via WriteMCPConfig with agent context injected.
type MCPConfigData struct {
Servers map[string]any // external MCP server entries (stdio/sse/http)
GatewayAddr string
GatewayToken string
AgentMCPLookup MCPServerLookup // optional: resolves per-agent MCP servers from DB
}
// BuildCLIMCPConfigData builds the base MCP server map from config.
// Does NOT include the goclaw-bridge entry — that's added per-session
// with agent context headers in WriteMCPConfig.
func BuildCLIMCPConfigData(servers map[string]*config.MCPServerConfig, gatewayAddr string, gatewayToken ...string) *MCPConfigData {
mcpServers := make(map[string]any, len(servers))
for name, srv := range servers {
if !srv.IsEnabled() {
continue
}
entry := mcpServerEntryToConfig(MCPServerEntry{
Name: name,
Transport: srv.Transport,
Command: srv.Command,
Args: srv.Args,
URL: srv.URL,
Headers: srv.Headers,
Env: srv.Env,
})
if len(entry) > 0 {
mcpServers[name] = entry
}
}
token := ""
if len(gatewayToken) > 0 {
token = gatewayToken[0]
}
return &MCPConfigData{
Servers: mcpServers,
GatewayAddr: gatewayAddr,
GatewayToken: token,
}
}
// mcpConfigBaseDir returns dataDir/mcp-configs, separate from workDir
// so agent cannot read tokens from the MCP config files.
func mcpConfigBaseDir() string {
return filepath.Join(config.ResolvedDataDirFromEnv(), "mcp-configs")
}
// BridgeContext holds per-call context for MCP bridge headers.
type BridgeContext struct {
AgentID string
UserID string
Channel string
ChatID string
PeerKind string
Workspace string
TenantID string
LocalKey string
DelegationID string
DelegationInputs string
}
// WriteMCPConfig writes a per-session MCP config file with agent context headers.
// Files are stored at ~/.goclaw/mcp-configs/<safe-session-key>/mcp-config.json,
// outside the agent's workDir so tokens are not exposed.
// Skips write if content is unchanged. Returns the file path.
func (d *MCPConfigData) WriteMCPConfig(ctx context.Context, sessionKey string, bc BridgeContext) string {
return d.writeMCPConfigInternal(
ctx,
sessionKey,
bc.AgentID,
bc.UserID,
bc.Channel,
bc.ChatID,
bc.PeerKind,
bc.Workspace,
bc.TenantID,
bc.LocalKey,
bc.DelegationID,
bc.DelegationInputs,
)
}
func (d *MCPConfigData) writeMCPConfigInternal(
ctx context.Context,
sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID, localKey string,
delegationID, delegationInputs string,
) string {
if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) {
return ""
}
// Shallow-copy the outer map so we can add the bridge entry without mutating the shared base.
// Inner server entries are not modified, so shallow copy is sufficient.
servers := make(map[string]any, len(d.Servers)+1)
maps.Copy(servers, d.Servers)
// Inject per-agent MCP servers from DB (if lookup is configured and agentID is set)
if d.AgentMCPLookup != nil && agentID != "" {
for _, srv := range d.AgentMCPLookup(ctx, agentID) {
if _, exists := servers[srv.Name]; exists {
continue // don't override static/bridge entries
}
entry := mcpServerEntryToConfig(srv)
if len(entry) > 0 {
servers[srv.Name] = entry
}
}
}
// Build bridge entry with per-session agent context headers
if d.GatewayAddr != "" {
headers := make(map[string]string)
if d.GatewayToken != "" {
headers["Authorization"] = "Bearer " + d.GatewayToken
}
if agentID != "" && !strings.ContainsAny(agentID, "\r\n\x00") {
headers["X-Agent-ID"] = agentID
}
if userID != "" && !strings.ContainsAny(userID, "\r\n\x00") {
headers["X-User-ID"] = userID
}
if channel != "" && !strings.ContainsAny(channel, "\r\n\x00") {
headers["X-Channel"] = channel
}
if chatID != "" && !strings.ContainsAny(chatID, "\r\n\x00") {
headers["X-Chat-ID"] = chatID
}
if peerKind != "" && !strings.ContainsAny(peerKind, "\r\n\x00") {
headers["X-Peer-Kind"] = peerKind
}
if workspace != "" && !strings.ContainsAny(workspace, "\r\n\x00") {
headers["X-Workspace"] = workspace
}
if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") {
headers["X-Tenant-ID"] = tenantID
}
if localKey != "" && !strings.ContainsAny(localKey, "\r\n\x00") {
headers["X-Local-Key"] = localKey
}
if sessionKey != "" && !strings.ContainsAny(sessionKey, "\r\n\x00") {
headers["X-Session-Key"] = sessionKey
}
if delegationID != "" && !strings.ContainsAny(delegationID, "\r\n\x00") {
headers["X-Delegation-ID"] = delegationID
}
if delegationInputs != "" && !strings.ContainsAny(delegationInputs, "\r\n\x00") {
headers["X-Delegation-Inputs"] = delegationInputs
}
// HMAC signature over all context fields to prevent header forgery
if d.GatewayToken != "" && (agentID != "" || userID != "") {
extra := []string{localKey, sessionKey}
if delegationID != "" || delegationInputs != "" {
extra = append(extra, delegationID, delegationInputs)
}
headers["X-Bridge-Sig"] = SignBridgeContext(
d.GatewayToken,
agentID,
userID,
channel,
chatID,
peerKind,
workspace,
tenantID,
extra...,
)
}
bridgeEntry := map[string]any{
"url": fmt.Sprintf("http://%s/mcp/bridge", d.GatewayAddr),
"type": "http",
}
if len(headers) > 0 {
bridgeEntry["headers"] = headers
}
servers["goclaw-bridge"] = bridgeEntry
}
if len(servers) == 0 {
return ""
}
data, err := json.MarshalIndent(map[string]any{"mcpServers": servers}, "", " ")
if err != nil {
slog.Warn("claude-cli: failed to marshal mcp config", "error", err)
return ""
}
// Write to per-session dir outside workDir
safe := sanitizePathSegment(sessionKey)
dir := filepath.Join(mcpConfigBaseDir(), safe)
if err := os.MkdirAll(dir, 0700); err != nil {
slog.Warn("claude-cli: failed to create mcp config dir", "error", err)
return ""
}
path := filepath.Join(dir, "mcp-config.json")
// Skip write if unchanged
if existing, err := os.ReadFile(path); err == nil && string(existing) == string(data) {
return path
}
// Atomic write: temp file + rename to prevent partial reads
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
slog.Warn("claude-cli: failed to write mcp config tmp", "error", err)
return ""
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
slog.Warn("claude-cli: failed to rename mcp config", "error", err)
return ""
}
return path
}
// mcpServerEntryToConfig converts an MCPServerEntry to the CLI MCP config format.
func mcpServerEntryToConfig(srv MCPServerEntry) map[string]any {
entry := make(map[string]any)
switch srv.Transport {
case "stdio":
if srv.Command != "" {
entry["command"] = srv.Command
}
if len(srv.Args) > 0 {
entry["args"] = srv.Args
}
if len(srv.Env) > 0 {
entry["env"] = srv.Env
}
case "sse":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "sse"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
case "streamable-http":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "http"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
}
return entry
}
// sanitizePathSegment makes a string safe for use as a single filesystem directory name.
// Replaces path separators and special chars, strips null bytes, handles ".." traversal,
// and truncates to 255 chars.
func sanitizePathSegment(s string) string {
safe := strings.NewReplacer(":", "-", "/", "-", "\\", "-", "\x00", "").Replace(s)
// Collapse any ".." sequences to prevent traversal
safe = strings.ReplaceAll(safe, "..", "_")
if len(safe) > 255 {
safe = safe[:255]
}
if safe == "" || safe == "." {
safe = "default"
}
return safe
}
// SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery.
// Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string, extra ...string) string {
mac := hmac.New(sha256.New, []byte(key))
var payload strings.Builder
payload.WriteString(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID)
for _, e := range extra {
payload.WriteString("|" + e)
}
mac.Write([]byte(payload.String()))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyBridgeContext checks the HMAC signature against the expected bridge context.
// Returns (ok, tenantVerified): ok indicates signature is valid, tenantVerified indicates
// the tenantID field was covered by the HMAC (only true at level 1).
// Falls back to old formats for backward compatibility with sessions whose MCP config
// was written before the workspace or tenantID fields were added.
// Callers must NOT trust the tenantID header when tenantVerified is false.
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string, extra ...string) (bool, bool) {
// Current format: all fields including localKey
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, extra...)
if hmac.Equal([]byte(expected), []byte(sig)) {
return true, true
}
// Fallback: without extra fields (pre-localKey sessions)
noExtra := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID)
if hmac.Equal([]byte(noExtra), []byte(sig)) {
return true, true
}
// Fallback: without tenantID (pre-tenantID sessions)
noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "")
if hmac.Equal([]byte(noTenant), []byte(sig)) {
return true, false
}
// Fallback: without workspace or tenantID (oldest sessions)
old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "", "")
if hmac.Equal([]byte(old), []byte(sig)) {
return true, false
}
return false, false
}