Skip to content

Commit 7d6f280

Browse files
JAORMXdmjbamirejaz
authored
Implement .thvignore-driven bind mount filtering with tmpfs overlays (#1137)
This PR implements a comprehensive security filtering system for MCP server containers that uses .thvignore files (similar to .gitignore) to exclude sensitive files and directories from container access while maintaining real-time bind mount functionality for development workflows. Signed-off-by: Juan Antonio Osorio <[email protected]> Co-authored-by: Don Browne <[email protected]> Co-authored-by: amirejaz <[email protected]>
1 parent e66e989 commit 7d6f280

File tree

17 files changed

+1513
-26
lines changed

17 files changed

+1513
-26
lines changed

cmd/thv/app/run_flags.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ type RunFlags struct {
7373

7474
// Configuration import
7575
FromConfig string
76+
77+
// Ignore functionality
78+
IgnoreGlobally bool
79+
PrintOverlays bool
7680
}
7781

7882
// AddRunFlags adds all the run flags to a command
@@ -160,6 +164,12 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
160164
"Filter MCP server tools (comma-separated list of tool names)",
161165
)
162166
cmd.Flags().StringVar(&config.FromConfig, "from-config", "", "Load configuration from exported file")
167+
168+
// Ignore functionality flags
169+
cmd.Flags().BoolVar(&config.IgnoreGlobally, "ignore-globally", true,
170+
"Load global ignore patterns from ~/.config/toolhive/thvignore")
171+
cmd.Flags().BoolVar(&config.PrintOverlays, "print-resolved-overlays", false,
172+
"Debug: show resolved container paths for tmpfs overlays")
163173
}
164174

165175
// BuildRunnerConfig creates a runner.RunConfig from the configuration
@@ -267,6 +277,8 @@ func BuildRunnerConfig(
267277
types.ProxyMode(runConfig.ProxyMode),
268278
runConfig.Group,
269279
runConfig.ToolsFilter,
280+
runConfig.IgnoreGlobally,
281+
runConfig.PrintOverlays,
270282
)
271283
}
272284

docs/cli/thv_run.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/container/docker/client.go

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/stacklok/toolhive/pkg/container/docker/sdk"
2727
"github.com/stacklok/toolhive/pkg/container/images"
2828
"github.com/stacklok/toolhive/pkg/container/runtime"
29+
"github.com/stacklok/toolhive/pkg/ignore"
2930
lb "github.com/stacklok/toolhive/pkg/labels"
3031
"github.com/stacklok/toolhive/pkg/logger"
3132
"github.com/stacklok/toolhive/pkg/networking"
@@ -83,12 +84,21 @@ func convertEnvVars(envVars map[string]string) []string {
8384
func convertMounts(mounts []runtime.Mount) []mount.Mount {
8485
result := make([]mount.Mount, 0, len(mounts))
8586
for _, m := range mounts {
86-
result = append(result, mount.Mount{
87-
Type: mount.TypeBind,
88-
Source: m.Source,
89-
Target: m.Target,
90-
ReadOnly: m.ReadOnly,
91-
})
87+
if m.Type == runtime.MountTypeTmpfs {
88+
// Create tmpfs mount to mask/hide sensitive directories
89+
result = append(result, mount.Mount{
90+
Type: mount.TypeTmpfs,
91+
Target: m.Target,
92+
// No TmpfsOptions needed - default size is sufficient for masking
93+
})
94+
} else {
95+
result = append(result, mount.Mount{
96+
Type: mount.TypeBind,
97+
Source: m.Source,
98+
Target: m.Target,
99+
ReadOnly: m.ReadOnly,
100+
})
101+
}
92102
}
93103
return result
94104
}
@@ -432,6 +442,8 @@ func generatePortBindings(labels map[string]string,
432442
// DeployWorkload creates and starts a workload.
433443
// It configures the workload based on the provided permission profile and transport type.
434444
// If options is nil, default options will be used.
445+
//
446+
//nolint:gocyclo // This function has high complexity due to comprehensive workload setup
435447
func (c *Client) DeployWorkload(
436448
ctx context.Context,
437449
image,
@@ -445,7 +457,11 @@ func (c *Client) DeployWorkload(
445457
isolateNetwork bool,
446458
) (string, int, error) {
447459
// Get permission config from profile
448-
permissionConfig, err := c.getPermissionConfigFromProfile(permissionProfile, transportType)
460+
var ignoreConfig *ignore.Config
461+
if options != nil {
462+
ignoreConfig = options.IgnoreConfig
463+
}
464+
permissionConfig, err := c.getPermissionConfigFromProfile(permissionProfile, transportType, ignoreConfig)
449465
if err != nil {
450466
return "", 0, fmt.Errorf("failed to get permission config: %w", err)
451467
}
@@ -894,7 +910,11 @@ func (c *Client) IsRunning(ctx context.Context) error {
894910
// getPermissionConfigFromProfile converts a permission profile to a container permission config
895911
// with transport-specific settings (internal function)
896912
// addReadOnlyMounts adds read-only mounts to the permission config
897-
func (*Client) addReadOnlyMounts(config *runtime.PermissionConfig, mounts []permissions.MountDeclaration) {
913+
func (*Client) addReadOnlyMounts(
914+
config *runtime.PermissionConfig,
915+
mounts []permissions.MountDeclaration,
916+
ignoreConfig *ignore.Config,
917+
) {
898918
for _, mountDecl := range mounts {
899919
source, target, err := mountDecl.Parse()
900920
if err != nil {
@@ -919,12 +939,20 @@ func (*Client) addReadOnlyMounts(config *runtime.PermissionConfig, mounts []perm
919939
Source: absPath,
920940
Target: target,
921941
ReadOnly: true,
942+
Type: runtime.MountTypeBind,
922943
})
944+
945+
// Process ignore patterns and add tmpfs overlays
946+
addIgnoreOverlays(config, absPath, target, ignoreConfig)
923947
}
924948
}
925949

926950
// addReadWriteMounts adds read-write mounts to the permission config
927-
func (*Client) addReadWriteMounts(config *runtime.PermissionConfig, mounts []permissions.MountDeclaration) {
951+
func (*Client) addReadWriteMounts(
952+
config *runtime.PermissionConfig,
953+
mounts []permissions.MountDeclaration,
954+
ignoreConfig *ignore.Config,
955+
) {
928956
for _, mountDecl := range mounts {
929957
source, target, err := mountDecl.Parse()
930958
if err != nil {
@@ -962,8 +990,62 @@ func (*Client) addReadWriteMounts(config *runtime.PermissionConfig, mounts []per
962990
Source: absPath,
963991
Target: target,
964992
ReadOnly: false,
993+
Type: runtime.MountTypeBind,
965994
})
966995
}
996+
997+
// Process ignore patterns and add tmpfs overlays
998+
addIgnoreOverlays(config, absPath, target, ignoreConfig)
999+
}
1000+
}
1001+
1002+
// addIgnoreOverlays processes ignore patterns for a mount and adds overlay mounts
1003+
func addIgnoreOverlays(config *runtime.PermissionConfig, sourceDir, containerPath string, ignoreConfig *ignore.Config) {
1004+
// Skip if no ignore configuration is provided
1005+
if ignoreConfig == nil {
1006+
return
1007+
}
1008+
1009+
// Create ignore processor with configuration
1010+
ignoreProcessor := ignore.NewProcessor(ignoreConfig)
1011+
1012+
// Load global ignore patterns if enabled
1013+
if ignoreConfig.LoadGlobal {
1014+
if err := ignoreProcessor.LoadGlobal(); err != nil {
1015+
logger.Debugf("Failed to load global ignore patterns: %v", err)
1016+
// Continue without global patterns
1017+
}
1018+
}
1019+
1020+
// Load local ignore patterns from the source directory
1021+
if err := ignoreProcessor.LoadLocal(sourceDir); err != nil {
1022+
logger.Debugf("Failed to load local ignore patterns from %s: %v", sourceDir, err)
1023+
// Continue without local patterns
1024+
}
1025+
1026+
// Get overlay mounts (both tmpfs for directories and bind for files)
1027+
overlayMounts := ignoreProcessor.GetOverlayMounts(sourceDir, containerPath)
1028+
1029+
// Add overlay mounts to the configuration
1030+
for _, overlayMount := range overlayMounts {
1031+
var mountType runtime.MountType
1032+
var source string
1033+
1034+
if overlayMount.Type == "tmpfs" {
1035+
mountType = runtime.MountTypeTmpfs
1036+
source = "" // No source for tmpfs
1037+
} else {
1038+
mountType = runtime.MountTypeBind
1039+
source = overlayMount.HostPath
1040+
}
1041+
1042+
config.Mounts = append(config.Mounts, runtime.Mount{
1043+
Source: source,
1044+
Target: overlayMount.ContainerPath,
1045+
ReadOnly: false,
1046+
Type: mountType,
1047+
})
1048+
logger.Debugf("Added %s overlay for ignored path: %s -> %s", overlayMount.Type, source, overlayMount.ContainerPath)
9671049
}
9681050
}
9691051

@@ -992,6 +1074,7 @@ func convertRelativePathToAbsolute(source string, mountDecl permissions.MountDec
9921074
func (c *Client) getPermissionConfigFromProfile(
9931075
profile *permissions.Profile,
9941076
transportType string,
1077+
ignoreConfig *ignore.Config,
9951078
) (*runtime.PermissionConfig, error) {
9961079
// Start with a default permission config
9971080
config := &runtime.PermissionConfig{
@@ -1003,8 +1086,8 @@ func (c *Client) getPermissionConfigFromProfile(
10031086
}
10041087

10051088
// Add mounts
1006-
c.addReadOnlyMounts(config, profile.Read)
1007-
c.addReadWriteMounts(config, profile.Write)
1089+
c.addReadOnlyMounts(config, profile.Read, ignoreConfig)
1090+
c.addReadWriteMounts(config, profile.Write, ignoreConfig)
10081091

10091092
// Validate transport type
10101093
switch transportType {

pkg/container/runtime/types.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"time"
1010

11+
"github.com/stacklok/toolhive/pkg/ignore"
1112
"github.com/stacklok/toolhive/pkg/permissions"
1213
)
1314

@@ -157,6 +158,21 @@ const (
157158
TypeKubernetes Type = "kubernetes"
158159
)
159160

161+
// MountType represents the type of mount
162+
type MountType string
163+
164+
const (
165+
// MountTypeBind represents a bind mount
166+
MountTypeBind MountType = "bind"
167+
// MountTypeTmpfs represents a tmpfs mount
168+
MountTypeTmpfs MountType = "tmpfs"
169+
)
170+
171+
// String returns the string representation of the mount type
172+
func (mt MountType) String() string {
173+
return string(mt)
174+
}
175+
160176
// PermissionConfig represents container permission configuration
161177
type PermissionConfig struct {
162178
// Mounts is the list of volume mounts
@@ -196,6 +212,10 @@ type DeployWorkloadOptions struct {
196212
// SSEHeadlessServiceName is the name of the Kubernetes service to use for the workload
197213
// Only applicable when using Kubernetes runtime and SSE transport
198214
SSEHeadlessServiceName string
215+
216+
// IgnoreConfig contains configuration for ignore patterns and tmpfs overlays
217+
// Used to filter bind mount contents by hiding sensitive files
218+
IgnoreConfig *ignore.Config
199219
}
200220

201221
// PortBinding represents a host port binding
@@ -226,6 +246,8 @@ type Mount struct {
226246
Target string
227247
// ReadOnly indicates if the mount is read-only
228248
ReadOnly bool
249+
// Type is the mount type (bind or tmpfs)
250+
Type MountType
229251
}
230252

231253
// IsKubernetesRuntime returns true if the runtime is Kubernetes

0 commit comments

Comments
 (0)