Files
goclaw/internal/providers/claude_cli.go
T
Nam Nguyen Ngoc 11bed0cc01 fix(mcp-bridge): per-session security context + media forwarding (#91)
* fix(mcp-bridge): add per-session agent context and HMAC verification

- Add per-session MCP config with X-Agent-ID/X-User-ID headers instead
  of shared global config file
- Sign bridge context headers with HMAC-SHA256 to prevent forgery
- Add bridgeContextMiddleware to verify signatures on MCP bridge requests
- Store MCP configs in ~/.goclaw/mcp-configs/ outside agent workDir
- Use atomic writes (tmp + rename) for MCP config files
- Fix provider rename leaving ghost registry entries
- Remove provider_type from mutable fields on update
- Tighten temp dir permissions from 0755 to 0700

* feat(mcp-bridge): propagate channel routing context through MCP bridge

- Pass channel, chat_id, and peer_kind from agent loop to CLI provider options
- Inject X-Channel, X-Chat-ID, X-Peer-Kind headers in bridge context middleware
- Add BridgeContext struct to bundle per-call context for MCP config generation
- Include channel routing headers in per-session MCP config files
- Expose "message" tool via MCP bridge for cross-channel messaging
- Add extract helpers for new option keys in claude_cli_session.go

* feat(mcp-bridge): forward media attachments to outbound message bus

- Wire MessageBus into gateway server and MCP bridge handler
- Publish tool result media files to outbound bus for channel delivery
- Extract channel/chatID/peerKind from tool context for proper routing
- Add mimeFromExt helper for content-type detection on attachments

* feat(mcp-bridge): inject per-agent DB-backed MCP servers into Claude CLI config

- Add MCPServerLookup type to resolve agent-specific MCP servers from DB
- Wire MCPServerStore through provider registration and HTTP handler
- Extract mcpServerEntryToConfig helper to deduplicate transport config logic
- Add JSON-to-Go helpers (jsonToStringSlice, jsonToStringMap) for DB fields
- Merge per-agent MCP servers at config write time without overriding static entries

* fix(mcp-bridge): use Media struct fields and prefer explicit MimeType

- Map Media.Path to attachment URL instead of treating Media as string
- Use Media.MimeType when available, fall back to extension-based detection

* refactor(providers): deduplicate option extractors and extract bridge media forwarding

- Replace per-field extractors (extractSessionKey, extractAgentID, etc.) with generic extractStringOpt/extractBoolOpt
- Add bridgeContextFromOpts helper to build BridgeContext in one call
- Extract forwardMediaToOutbound from inline block in makeToolHandler
- Change NewBridgeServer msgBus param from variadic to explicit pointer

* fix(providers): validate provider_type on update instead of silently dropping it

- Add explicit validation against ValidProviderTypes with 400 response
- Remove silent delete(updates, "provider_type") that hid invalid values
- Caller now receives clear error when submitting unsupported provider_type

* fix(providers): add header injection validation to MCP bridge headers

- Extend CRLF/null-byte checks to agentID, channel, chatID, and peerKind
- Previously only userID had header injection prevention
- Prevents HTTP header injection via crafted values in MCP config

* fix(mcp-bridge): sign all context fields in HMAC and remove legacy code

- Sign all 5 bridge context fields (agentID|userID|channel|chatID|peerKind)
  in HMAC instead of only agentID|userID to prevent channel routing forgery
- Propagate context.Context into MCPServerLookup to respect request
  cancellation instead of using context.Background()
- Remove legacy BuildCLIMCPConfig, WithClaudeCLIMCPConfig, mcpConfigPath,
  and mcpCleanup (dead code since system is PG-only)
- Use mime.TypeByExtension before custom fallback in mimeFromExt
- Add debug log when media forwarding is skipped due to missing context
- Add thread-safety comment to SetMCPServerLookup

---------

Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-03-09 15:23:56 +07:00

144 lines
4.8 KiB
Go

package providers
import (
"log/slog"
"os"
"sync"
)
// Options key for passing session key from agent loop to CLI provider.
const OptSessionKey = "session_key"
// OptDisableTools disables all built-in CLI tools when set to true.
// Useful for pure text generation (e.g. summoning) where tool use is unwanted.
const OptDisableTools = "disable_tools"
// OptAgentID passes the agent UUID string for per-session MCP config.
const OptAgentID = "agent_id"
// OptUserID passes the user ID string for per-session MCP config.
const OptUserID = "user_id"
// OptChannel passes the source channel (telegram, discord, etc.) for MCP bridge context.
const OptChannel = "channel"
// OptChatID passes the source chat ID for MCP bridge context.
const OptChatID = "chat_id"
// OptPeerKind passes the peer kind (direct/group) for MCP bridge context.
const OptPeerKind = "peer_kind"
// ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary.
// It acts as a thin proxy: CLI manages session history, tool execution, and context.
// GoClaw only forwards the latest user message and streams back the response.
type ClaudeCLIProvider struct {
cliPath string // path to claude binary (default: "claude")
defaultModel string // default: "sonnet"
baseWorkDir string // base dir for agent workspaces
mcpConfigData *MCPConfigData // per-session MCP config data
permMode string // permission mode (default: "bypassPermissions")
hooksSettingsPath string // generated settings.json with security hooks (empty = no hooks)
hooksCleanup func() // cleanup function for hooks temp files
mu sync.Mutex // protects workdir creation
sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock
mcpConfigDirs sync.Map // key: string (dir path), value: struct{} — tracks per-session MCP config dirs for cleanup
}
// ClaudeCLIOption configures the provider.
type ClaudeCLIOption func(*ClaudeCLIProvider)
// WithClaudeCLIModel sets the default model alias.
func WithClaudeCLIModel(model string) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
if model != "" {
p.defaultModel = model
}
}
}
// WithClaudeCLIWorkDir sets the base work directory.
func WithClaudeCLIWorkDir(dir string) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
if dir != "" {
p.baseWorkDir = dir
}
}
}
// WithClaudeCLIMCPConfigData sets the per-session MCP config data.
// Per-session configs are written on each Chat/ChatStream call with agent context.
func WithClaudeCLIMCPConfigData(data *MCPConfigData) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
p.mcpConfigData = data
}
}
// WithClaudeCLIPermMode sets the permission mode.
func WithClaudeCLIPermMode(mode string) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
if mode != "" {
p.permMode = mode
}
}
}
// WithClaudeCLISecurityHooks enables GoClaw security hooks for CLI tool calls.
// Generates a settings file with PreToolUse hooks that enforce shell deny patterns
// and workspace path restrictions.
func WithClaudeCLISecurityHooks(workspace string, restrictToWorkspace bool) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
settingsPath, cleanup, err := BuildCLIHooksConfig(workspace, restrictToWorkspace)
if err != nil {
slog.Warn("claude-cli: failed to build security hooks", "error", err)
return
}
p.hooksSettingsPath = settingsPath
p.hooksCleanup = cleanup
}
}
// NewClaudeCLIProvider creates a provider that invokes the claude CLI.
func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIProvider {
if cliPath == "" {
cliPath = "claude"
}
p := &ClaudeCLIProvider{
cliPath: cliPath,
defaultModel: "sonnet",
baseWorkDir: defaultCLIWorkDir(),
permMode: "bypassPermissions",
// sessionMu is zero-value ready (sync.Map)
}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *ClaudeCLIProvider) Name() string { return "claude-cli" }
func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel }
// Close cleans up temp files (per-session MCP configs, hooks settings). Implements io.Closer.
func (p *ClaudeCLIProvider) Close() error {
// Clean up per-session MCP config directories this provider created
p.mcpConfigDirs.Range(func(key, _ any) bool {
dir := key.(string)
if err := os.RemoveAll(dir); err != nil {
slog.Warn("claude-cli: failed to clean mcp config dir", "dir", dir, "error", err)
}
return true
})
if p.hooksCleanup != nil {
p.hooksCleanup()
}
return nil
}
// lockSession acquires a per-session mutex to prevent concurrent CLI calls on the same session.
func (p *ClaudeCLIProvider) lockSession(sessionKey string) func() {
actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{})
m := actual.(*sync.Mutex)
m.Lock()
return m.Unlock
}