Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions cmd/internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ import (
)

type Config struct {
Sources server.SourceConfigs `yaml:"sources"`
AuthServices server.AuthServiceConfigs `yaml:"authServices"`
EmbeddingModels server.EmbeddingModelConfigs `yaml:"embeddingModels"`
Tools server.ToolConfigs `yaml:"tools"`
Toolsets server.ToolsetConfigs `yaml:"toolsets"`
Prompts server.PromptConfigs `yaml:"prompts"`
Sources server.SourceConfigs `yaml:"sources"`
AuthServices server.AuthServiceConfigs `yaml:"authServices"`
EmbeddingModels server.EmbeddingModelConfigs `yaml:"embeddingModels"`
Tools server.ToolConfigs `yaml:"tools"`
Toolsets server.ToolsetConfigs `yaml:"toolsets"`
Prompts server.PromptConfigs `yaml:"prompts"`
Resources server.ResourceConfigs `yaml:"resources"`
ResourceTemplates server.ResourceConfigs `yaml:"resourceTemplates"`
}

type ConfigParser struct {
Expand Down Expand Up @@ -150,10 +152,18 @@ func (p *ConfigParser) ParseConfig(ctx context.Context, raw []byte) (Config, err
}

// Parse contents
config.Sources, config.AuthServices, config.EmbeddingModels, config.Tools, config.Toolsets, config.Prompts, err = server.UnmarshalResourceConfig(ctx, raw)
unmarshaled, err := server.UnmarshalConfigs(ctx, raw)
if err != nil {
return config, err
}
config.Sources = unmarshaled.Sources
config.AuthServices = unmarshaled.AuthServices
config.EmbeddingModels = unmarshaled.EmbeddingModels
config.Tools = unmarshaled.Tools
config.Toolsets = unmarshaled.Toolsets
config.Prompts = unmarshaled.Prompts
config.Resources = unmarshaled.Resources
config.ResourceTemplates = unmarshaled.ResourceTemplates
return config, nil
}

Expand All @@ -180,7 +190,7 @@ func ConvertConfig(raw []byte) ([]byte, error) {
decoder := yaml.NewDecoder(bytes.NewReader(raw), yaml.UseOrderedMap())
encoder := yaml.NewEncoder(&buf, yaml.UseLiteralStyleIfMultiline(true))

nestedFormatKey := []string{"sources", "authServices", "embeddingModels", "tools", "toolsets", "prompts"}
nestedFormatKey := []string{"sources", "authServices", "embeddingModels", "tools", "toolsets", "prompts", "resources", "resourceTemplates"}
docIndex := 0
for {
if err := decoder.Decode(&input); err != nil {
Expand Down Expand Up @@ -217,6 +227,10 @@ func ConvertConfig(raw []byte) ([]byte, error) {
key = "toolset"
case "prompts":
key = "prompt"
case "resources":
key = "resource"
case "resourceTemplates":
key = "resourceTemplate"
}
transformed, err := transformDocs(key, slice)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/internal/invoke/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func runInvoke(cmd *cobra.Command, args []string, opts *internal.ToolboxOptions)
}

// Initialize Resources
sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, opts.Cfg)
sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, _, _, err := server.InitializeConfigs(ctx, opts.Cfg)
if err != nil {
errMsg := fmt.Errorf("failed to initialize resources: %w", err)
opts.Logger.ErrorContext(ctx, errMsg.Error())
Expand Down
34 changes: 34 additions & 0 deletions cmd/internal/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"

Expand Down Expand Up @@ -304,6 +305,39 @@ func (opts *ToolboxOptions) LoadConfig(ctx context.Context, parser *ConfigParser
opts.Cfg.ToolConfigs = finalConfig.Tools
opts.Cfg.ToolsetConfigs = finalConfig.Toolsets
opts.Cfg.PromptConfigs = finalConfig.Prompts
opts.Cfg.ResourceConfigs = finalConfig.Resources
opts.Cfg.ResourceTemplateConfigs = finalConfig.ResourceTemplates

// Set configuration directory tracking
if opts.ConfigFolder != "" {
absDir, err := filepath.Abs(opts.ConfigFolder)
if err != nil {
return isCustomConfigured, fmt.Errorf("invalid config folder: %w", err)
}
opts.Cfg.ConfigDir = absDir
opts.Cfg.UsingConfigFolder = true
} else if opts.Config != "" {
absDir, err := filepath.Abs(filepath.Dir(opts.Config))
if err != nil {
return isCustomConfigured, fmt.Errorf("invalid config file path: %w", err)
}
opts.Cfg.ConfigDir = absDir
opts.Cfg.UsingConfigFolder = false
} else if len(opts.Configs) > 0 {
absDir, err := filepath.Abs(filepath.Dir(opts.Configs[0]))
if err != nil {
return isCustomConfigured, fmt.Errorf("invalid config path: %w", err)
}
opts.Cfg.ConfigDir = absDir
opts.Cfg.UsingConfigFolder = false
} else {
absDir, err := filepath.Abs(".")
if err != nil {
return isCustomConfigured, fmt.Errorf("failed to resolve current directory: %w", err)
}
opts.Cfg.ConfigDir = absDir
opts.Cfg.UsingConfigFolder = false
}

return isCustomConfigured, nil
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func validateReloadEdits(
IgnoreUnknownTools: util.IgnoreUnknownToolsFromContext(ctx),
}

sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, reloadedConfig)
sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, _, _, err := server.InitializeConfigs(ctx, reloadedConfig)
if err != nil {
errMsg := fmt.Errorf("unable to initialize reloaded configs: %w", err)
logger.WarnContext(ctx, errMsg.Error())
Expand Down
231 changes: 231 additions & 0 deletions internal/resources/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package resources

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

yaml "github.com/goccy/go-yaml"
)

const FileResourceType = "file"
const MaxFileSize = 5 * 1024 * 1024 // 5 MB

func init() {
Register(FileResourceType, newFileConfig)
}

func newFileConfig(ctx context.Context, name string, decoder *yaml.Decoder) (ResourceConfig, error) {
cfg := &FileConfig{Name: name}
if err := decoder.DecodeContext(ctx, cfg); err != nil {
return nil, fmt.Errorf("failed to decode file resource config %q: %w", name, err)
}
return cfg, nil
}

// FileConfig represents the configuration for a file-based resource or template.
type FileConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
URI string `yaml:"uri"`
Description string `yaml:"description,omitempty"`
Title string `yaml:"title,omitempty"`
MimeType string `yaml:"mimeType,omitempty"`
AllowedPaths []string `yaml:"allowedPaths,omitempty"`
Annotations *Annotations `yaml:"annotations,omitempty"`
}

func (c *FileConfig) ResourceConfigType() string {
return FileResourceType
}

// Initialize validates and prepares the FileResource.
func (c *FileConfig) Initialize(ctx context.Context, configDir string, usingConfigFolder bool) (Resource, error) {
// Strict Scheme Whitelisting
if !strings.HasPrefix(c.URI, "file://") {
return nil, fmt.Errorf("resource %q URI must start with file://", c.Name)
}

// Safety Check: Enforce allowed paths if started with config-folder
if usingConfigFolder && len(c.AllowedPaths) == 0 {
return nil, fmt.Errorf("security error: resource %q of type 'file' must explicitly define 'allowedPaths' when starting the server with a config directory flag", c.Name)
}

// Determine sandbox roots
var sandboxRoots []string
if len(c.AllowedPaths) > 0 {
for _, p := range c.AllowedPaths {
abs, err := filepath.Abs(p)
if err != nil {
return nil, fmt.Errorf("invalid allowed path %q for resource %q: %w", p, c.Name, err)
}
sandboxRoots = append(sandboxRoots, abs)
}
} else {
// Default sandbox is the directory where the configuration file lives
abs, err := filepath.Abs(configDir)
if err != nil {
return nil, fmt.Errorf("invalid config directory: %w", err)
}
sandboxRoots = append(sandboxRoots, abs)
}
Comment on lines +72 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The sandbox roots are initialized using filepath.Abs, which does not resolve symlinks. However, during a read operation, the requested path is resolved using filepath.EvalSymlinks. This mismatch can cause false-positive sandbox escape errors on systems where common directories are symlinks (for example, /var is a symlink to /private/var on macOS). Resolving symlinks for the sandbox roots during initialization prevents these false positives.

	if len(c.AllowedPaths) > 0 {
		for _, p := range c.AllowedPaths {
			abs, err := filepath.Abs(p)
			if err != nil {
				return nil, fmt.Errorf("invalid allowed path %q for resource %q: %w", p, c.Name, err)
			}
			if resolved, err := filepath.EvalSymlinks(abs); err == nil {
				abs = resolved
			}
			sandboxRoots = append(sandboxRoots, abs)
		}
	} else {
		// Default sandbox is the directory where the configuration file lives
		abs, err := filepath.Abs(configDir)
		if err != nil {
			return nil, fmt.Errorf("invalid config directory: %w", err)
		}
		if resolved, err := filepath.EvalSymlinks(abs); err == nil {
			abs = resolved
		}
		sandboxRoots = append(sandboxRoots, abs)
	}


// Default annotations: priority to 1.0 if not defined
ann := c.Annotations
if ann == nil {
prio := 1.0
ann = &Annotations{
Priority: &prio,
}
} else if ann.Priority == nil {
prio := 1.0
ann.Priority = &prio
}

return &FileResource{
cfg: c,
sandboxRoots: sandboxRoots,
ann: ann,
}, nil
}

// FileResource represents the initialized file resource.
type FileResource struct {
cfg *FileConfig
sandboxRoots []string
ann *Annotations
}

func (r *FileResource) ResourceName() string {
return r.cfg.Name
}

func (r *FileResource) ResourceURI() string {
return r.cfg.URI
}

func (r *FileResource) ResourceType() string {
return FileResourceType
}

func (r *FileResource) ResourceDescription() string {
return r.cfg.Description
}

func (r *FileResource) ResourceTitle() string {
return r.cfg.Title
}

func (r *FileResource) ResourceMimeType() string {
if r.cfg.MimeType != "" {
return r.cfg.MimeType
}
return "text/plain"
}

func (r *FileResource) ResourceAnnotations() *Annotations {
return r.ann
}

// Read resolves the requested path, applies strict sandboxing and security checks, and returns the file content.
func (r *FileResource) Read(ctx context.Context, params map[string]any) ([]ResourceContent, error) {
// Substitute template parameters if this is a template
actualURI := r.cfg.URI
if strings.Contains(actualURI, "{path}") {
val, ok := params["path"]
if !ok {
return nil, fmt.Errorf("missing required parameter 'path' for resource template %q", r.cfg.Name)
}
pathStr, ok := val.(string)
if !ok {
return nil, fmt.Errorf("parameter 'path' must be a string for resource template %q", r.cfg.Name)
}
actualURI = strings.ReplaceAll(actualURI, "{path}", pathStr)
}

// Extract file path from the URI
filePath := strings.TrimPrefix(actualURI, "file://")

// 1. Clean the path to resolve traversals like '../'
cleanedPath := filepath.Clean(filePath)

// 2. Resolve symlinks to find the true physical path
resolvedPath, err := filepath.EvalSymlinks(cleanedPath)
if err != nil {
return nil, fmt.Errorf("failed to access resource path: %w", err)
}

// 3. Sandbox Enforcement: verify path is strictly inside one of the sandbox roots
isInside := false
for _, root := range r.sandboxRoots {
if isPathInside(resolvedPath, root) {
isInside = true
break
}
}
if !isInside {
return nil, fmt.Errorf("permission denied: path %q escapes the allowed sandbox roots", filePath)
}

// 4. Hidden Files Check: block reading hidden files or directories (starting with '.')
segments := strings.Split(resolvedPath, string(filepath.Separator))
for _, seg := range segments {
if strings.HasPrefix(seg, ".") && seg != "." && seg != ".." {
return nil, fmt.Errorf("permission denied: reading hidden files or directories is not allowed")
}
}
Comment on lines +174 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The hidden files check splits the absolute resolvedPath and blocks access if any segment starts with a dot (.). If the toolbox or its configuration is deployed inside a hidden directory (such as /home/user/.config/toolbox/), this check will incorrectly block reading any files, even if they are completely safe and within the allowed sandbox. Checking only the segments of the path relative to the matched sandbox root solves this issue while still preventing access to hidden files/directories inside the sandbox.

	// 3. Sandbox Enforcement: verify path is strictly inside one of the sandbox roots
	isInside := false
	var matchedRoot string
	for _, root := range r.sandboxRoots {
		if isPathInside(resolvedPath, root) {
			isInside = true
			matchedRoot = root
			break
		}
	}
	if !isInside {
		return nil, fmt.Errorf("permission denied: path %q escapes the allowed sandbox roots", filePath)
	}

	// 4. Hidden Files Check: block reading hidden files or directories (starting with '.')
	// Only check segments relative to the matched sandbox root to allow running from hidden directories.
	relPath, err := filepath.Rel(matchedRoot, resolvedPath)
	if err != nil {
		return nil, fmt.Errorf("failed to get relative path: %w", err)
	}
	segments := strings.Split(relPath, string(filepath.Separator))
	for _, seg := range segments {
		if strings.HasPrefix(seg, ".") && seg != "." && seg != ".." {
			return nil, fmt.Errorf("permission denied: reading hidden files or directories is not allowed")
		}
	}


// 5. Size and Directory validation
info, err := os.Stat(resolvedPath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("invalid resource: %q is a directory, not a file", filePath)
}
if info.Size() > MaxFileSize {
return nil, fmt.Errorf("file size %d bytes exceeds the maximum limit of 5MB", info.Size())
}

// Read the file content
contentBytes, err := os.ReadFile(resolvedPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}

return []ResourceContent{
{
URI: actualURI,
MimeType: r.ResourceMimeType(),
Text: string(contentBytes),
},
}, nil
}

func (r *FileResource) ToConfig() ResourceConfig {
return r.cfg
}

// isPathInside returns true if path matches root or lies within root.
func isPathInside(path, root string) bool {
if path == root {
return true
}
return strings.HasPrefix(path, root+string(filepath.Separator))
}
Loading
Loading