This commit is contained in:
2026-01-18 18:40:26 +00:00
commit 185e73da47
51 changed files with 14073 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
// Package config provides configuration management for the MCP file operations server.
package config
import (
"os"
"path/filepath"
"strings"
"time"
json "github.com/goccy/go-json"
)
// Config holds all configuration options for the MCP server.
type Config struct {
Formatters map[string]string `json:"formatters"`
WorkspaceRoot string `json:"workspace_root"`
LSPTimeout time.Duration `json:"lsp_timeout"`
SearchTimeout time.Duration `json:"search_timeout"`
MaxFileSize int64 `json:"max_file_size"`
MaxSearchResults int `json:"max_search_results"`
MaxEditSize int64 `json:"max_edit_size"`
EnableLSP bool `json:"enable_lsp"`
FollowSymlinks bool `json:"follow_symlinks"`
RespectGitignore bool `json:"respect_gitignore"`
}
// Default values for configuration.
const (
DefaultLSPTimeout = 5 * time.Minute
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxEditSize = 100 * 1024 // 100 KB
)
// Default returns a Config with default values.
func Default() *Config {
return &Config{
WorkspaceRoot: ".",
LSPTimeout: DefaultLSPTimeout,
SearchTimeout: DefaultSearchTimeout,
MaxFileSize: DefaultMaxFileSize,
MaxSearchResults: DefaultMaxSearchResults,
MaxEditSize: DefaultMaxEditSize,
EnableLSP: true,
Formatters: make(map[string]string),
FollowSymlinks: true,
RespectGitignore: true,
}
}
// Load loads configuration from environment variables and optional config file.
// Priority: CLI flags > environment variables > config file > defaults.
func Load(workspaceRoot string) (*Config, error) {
cfg := Default()
// Set workspace root
if workspaceRoot != "" {
absPath, err := filepath.Abs(workspaceRoot)
if err != nil {
return nil, err
}
cfg.WorkspaceRoot = absPath
} else if cwd, err := os.Getwd(); err == nil {
cfg.WorkspaceRoot = cwd
}
// Try to load from config file in workspace root
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
// Override from environment variables
cfg.loadFromEnv()
return cfg, nil
}
func (c *Config) loadFromEnv() {
if v := os.Getenv("MCP_WORKSPACE_ROOT"); v != "" {
if absPath, err := filepath.Abs(v); err == nil {
c.WorkspaceRoot = absPath
}
}
if v := os.Getenv("MCP_LSP_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.LSPTimeout = d
}
}
if v := os.Getenv("MCP_SEARCH_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.SearchTimeout = d
}
}
if v := os.Getenv("MCP_ENABLE_LSP"); v == "false" || v == "0" {
c.EnableLSP = false
}
if v := os.Getenv("MCP_FOLLOW_SYMLINKS"); v == "false" || v == "0" {
c.FollowSymlinks = false
}
if v := os.Getenv("MCP_RESPECT_GITIGNORE"); v == "false" || v == "0" {
c.RespectGitignore = false
}
}
// IsPathAllowed checks if a path is within the workspace root.
// It resolves symlinks to prevent path traversal attacks.
func (c *Config) IsPathAllowed(path string) bool {
// Get absolute path of the target
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
// Get absolute path of workspace root
absRoot, err := filepath.Abs(c.WorkspaceRoot)
if err != nil {
return false
}
// Always try to resolve workspace root symlinks for consistent comparison
evalRoot, evalErr := filepath.EvalSymlinks(absRoot)
if evalErr == nil {
absRoot = evalRoot
}
// For the target path, try to resolve symlinks
evalPath, evalErr := filepath.EvalSymlinks(absPath)
if evalErr == nil {
// File exists and was resolved
absPath = evalPath
} else {
// File doesn't exist - resolve parent directories to match workspace root resolution
// Walk up the tree until we find an existing directory
dir := filepath.Dir(absPath)
remaining := filepath.Base(absPath)
for dir != "." && dir != "/" && dir != absPath {
evalDir, evalErr := filepath.EvalSymlinks(dir)
if evalErr == nil {
// Found an existing directory, reconstruct the path
absPath = filepath.Join(evalDir, remaining)
break
}
// Move up one level
newDir := filepath.Dir(dir)
if newDir == dir {
// Reached the root without finding an existing directory
break
}
remaining = filepath.Join(filepath.Base(dir), remaining)
dir = newDir
}
}
// Compute relative path
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
// Check if the path is within workspace (doesn't start with ..)
// This prevents both "../" attacks and symlink bypasses
// Also reject empty relative path (which means it's the workspace root itself)
return rel != "." && !strings.HasPrefix(rel, "..")
}
+184
View File
@@ -0,0 +1,184 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.WorkspaceRoot != "." {
t.Errorf("expected default workspace root '.', got %q", cfg.WorkspaceRoot)
}
if cfg.LSPTimeout != DefaultLSPTimeout {
t.Errorf("expected default LSP timeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
}
if cfg.SearchTimeout != DefaultSearchTimeout {
t.Errorf("expected default search timeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
}
if cfg.MaxFileSize != DefaultMaxFileSize {
t.Errorf("expected default max file size %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
}
if cfg.MaxSearchResults != DefaultMaxSearchResults {
t.Errorf("expected default max search results %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
}
if cfg.MaxEditSize != DefaultMaxEditSize {
t.Errorf("expected default max edit size %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
}
if !cfg.EnableLSP {
t.Error("expected EnableLSP to be true by default")
}
if !cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be true by default")
}
if !cfg.RespectGitignore {
t.Error("expected RespectGitignore to be true by default")
}
}
func TestLoad(t *testing.T) {
// Create a temporary directory for workspace
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
absPath, _ := filepath.Abs(tmpDir)
if cfg.WorkspaceRoot != absPath {
t.Errorf("expected workspace root %q, got %q", absPath, cfg.WorkspaceRoot)
}
}
func TestLoadFromEnv(t *testing.T) {
// Save original env values
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
origSearchTimeout := os.Getenv("MCP_SEARCH_TIMEOUT")
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
origFollowSymlinks := os.Getenv("MCP_FOLLOW_SYMLINKS")
origRespectGitignore := os.Getenv("MCP_RESPECT_GITIGNORE")
// Restore env after test
t.Cleanup(func() {
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
_ = os.Setenv("MCP_SEARCH_TIMEOUT", origSearchTimeout)
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", origFollowSymlinks)
_ = os.Setenv("MCP_RESPECT_GITIGNORE", origRespectGitignore)
})
// Set test env values
_ = os.Setenv("MCP_LSP_TIMEOUT", "10m")
_ = os.Setenv("MCP_SEARCH_TIMEOUT", "1m")
_ = os.Setenv("MCP_ENABLE_LSP", "false")
_ = os.Setenv("MCP_FOLLOW_SYMLINKS", "0")
_ = os.Setenv("MCP_RESPECT_GITIGNORE", "false")
cfg := Default()
cfg.loadFromEnv()
if cfg.LSPTimeout != 10*time.Minute {
t.Errorf("expected LSP timeout 10m, got %v", cfg.LSPTimeout)
}
if cfg.SearchTimeout != 1*time.Minute {
t.Errorf("expected search timeout 1m, got %v", cfg.SearchTimeout)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false")
}
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false")
}
if cfg.RespectGitignore {
t.Error("expected RespectGitignore to be false")
}
}
func TestIsPathAllowed(t *testing.T) {
// Create a temporary directory
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := Default()
cfg.WorkspaceRoot = tmpDir
tests := []struct {
name string
path string
allowed bool
}{
{
name: "file in workspace",
path: filepath.Join(tmpDir, "test.go"),
allowed: true,
},
{
name: "nested file in workspace",
path: filepath.Join(tmpDir, "subdir", "test.go"),
allowed: true,
},
{
name: "path outside workspace",
path: "/etc/passwd",
allowed: false,
},
{
name: "relative path traversal",
path: filepath.Join(tmpDir, "..", "outside.txt"),
allowed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.allowed {
t.Errorf("IsPathAllowed(%q) = %v, want %v", tt.path, result, tt.allowed)
}
})
}
}
func TestLoadWithConfigFile(t *testing.T) {
// Create a temporary directory
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Write config file
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"enable_lsp": false,
"follow_symlinks": false
}`
err = os.WriteFile(configPath, []byte(configContent), 0600)
if err != nil {
t.Fatalf("failed to write config file: %v", err)
}
var cfg *Config
cfg, err = Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false from config file")
}
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false from config file")
}
}
+141
View File
@@ -0,0 +1,141 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestIsPathAllowed_SymlinkSecurity tests the symlink security fix.
func TestIsPathAllowed_SymlinkSecurity(t *testing.T) {
// Create a temporary workspace
tmpDir := t.TempDir()
workspace := filepath.Join(tmpDir, "workspace")
outside := filepath.Join(tmpDir, "outside")
if err := os.MkdirAll(workspace, 0700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0700); err != nil {
t.Fatal(err)
}
// Create a file outside the workspace
outsideFile := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(outsideFile, []byte("secret data"), 0600); err != nil {
t.Fatal(err)
}
cfg := &Config{
WorkspaceRoot: workspace,
}
tests := []struct {
setup func() string
name string
expected bool
}{
{
name: "regular file inside workspace",
setup: func() string {
return filepath.Join(workspace, "file.txt")
},
expected: true,
},
{
name: "file with parent directory traversal",
setup: func() string {
return filepath.Join(workspace, "../outside/secret.txt")
},
expected: false,
},
{
name: "symlink pointing outside workspace",
setup: func() string {
symlink := filepath.Join(workspace, "link.txt")
_ = os.Symlink(outsideFile, symlink)
return symlink
},
expected: false,
},
{
name: "symlink pointing inside workspace",
setup: func() string {
inside := filepath.Join(workspace, "inside.txt")
_ = os.WriteFile(inside, []byte("ok"), 0600)
symlink := filepath.Join(workspace, "link_inside.txt")
_ = os.Symlink(inside, symlink)
return symlink
},
expected: true,
},
{
name: "dotfile inside workspace",
setup: func() string {
return filepath.Join(workspace, ".gitignore")
},
expected: true,
},
{
name: "hidden directory inside workspace",
setup: func() string {
return filepath.Join(workspace, ".git/config")
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.setup()
result := cfg.IsPathAllowed(path)
if result != tt.expected {
t.Errorf("IsPathAllowed(%q) = %v, want %v", path, result, tt.expected)
}
})
}
}
// TestIsPathAllowed_BasicCases tests basic path validation.
func TestIsPathAllowed_BasicCases(t *testing.T) {
tmpDir := t.TempDir()
cfg := &Config{
WorkspaceRoot: tmpDir,
}
tests := []struct {
name string
path string
expected bool
}{
{
name: "path inside workspace",
path: filepath.Join(tmpDir, "file.txt"),
expected: true,
},
{
name: "path outside workspace",
path: "/etc/passwd",
expected: false,
},
{
name: "parent directory reference",
path: filepath.Join(tmpDir, "../../../etc/passwd"),
expected: false,
},
{
name: "workspace root itself",
path: tmpDir,
expected: false, // Empty relative path
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.expected {
t.Errorf("IsPathAllowed(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
}