Skip to content

Commit 3e2584a

Browse files
committed
added validation for hostPath
Signed-off-by: Praful Khanduri <holiodin@gmail.com> added tests for hostPath validation Signed-off-by: Praful Khanduri <holiodin@gmail.com> return path translation logs to agent Signed-off-by: Praful Khanduri <holiodin@gmail.com> refactor : use constant for io.lima-vm/warnings Signed-off-by: Praful <holiodin@gmail.com> use path.Join for cross-platform abs paths Signed-off-by: Praful <holiodin@gmail.com>
1 parent 7bc8bc8 commit 3e2584a

File tree

4 files changed

+219
-24
lines changed

4 files changed

+219
-24
lines changed

pkg/mcp/toolset/filesystem.go

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ import (
1717
"github.com/lima-vm/lima/v2/pkg/ptr"
1818
)
1919

20+
const MetaWarnings = "io.lima-vm/warnings"
21+
2022
func (ts *ToolSet) ListDirectory(ctx context.Context,
2123
_ *mcp.CallToolRequest, args msi.ListDirectoryParams,
2224
) (*mcp.CallToolResult, *msi.ListDirectoryResult, error) {
2325
if ts.inst == nil {
2426
return nil, nil, errors.New("instance not registered")
2527
}
26-
guestPath, err := ts.TranslateHostPath(args.Path)
28+
guestPath, warnings, err := ts.TranslateHostPath(args.Path)
2729
if err != nil {
2830
return nil, nil, err
2931
}
@@ -41,9 +43,13 @@ func (ts *ToolSet) ListDirectory(ctx context.Context,
4143
res.Entries[i].ModTime = ptr.Of(f.ModTime())
4244
res.Entries[i].IsDir = ptr.Of(f.IsDir())
4345
}
44-
return &mcp.CallToolResult{
46+
callToolRes := &mcp.CallToolResult{
4547
StructuredContent: res,
46-
}, res, nil
48+
}
49+
if warnings != "" {
50+
callToolRes.Meta[MetaWarnings] = warnings
51+
}
52+
return callToolRes, res, nil
4753
}
4854

4955
func (ts *ToolSet) ReadFile(_ context.Context,
@@ -52,7 +58,7 @@ func (ts *ToolSet) ReadFile(_ context.Context,
5258
if ts.inst == nil {
5359
return nil, nil, errors.New("instance not registered")
5460
}
55-
guestPath, err := ts.TranslateHostPath(args.Path)
61+
guestPath, warnings, err := ts.TranslateHostPath(args.Path)
5662
if err != nil {
5763
return nil, nil, err
5864
}
@@ -70,12 +76,16 @@ func (ts *ToolSet) ReadFile(_ context.Context,
7076
res := &msi.ReadFileResult{
7177
Content: string(b),
7278
}
73-
return &mcp.CallToolResult{
79+
callToolRes := &mcp.CallToolResult{
7480
// Gemini:
7581
// For text files: The file content, potentially prefixed with a truncation message
7682
// (e.g., [File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...).
7783
StructuredContent: res,
78-
}, res, nil
84+
}
85+
if warnings != "" {
86+
callToolRes.Meta[MetaWarnings] = warnings
87+
}
88+
return callToolRes, res, nil
7989
}
8090

8191
func (ts *ToolSet) WriteFile(_ context.Context,
@@ -84,7 +94,7 @@ func (ts *ToolSet) WriteFile(_ context.Context,
8494
if ts.inst == nil {
8595
return nil, nil, errors.New("instance not registered")
8696
}
87-
guestPath, err := ts.TranslateHostPath(args.Path)
97+
guestPath, warnings, err := ts.TranslateHostPath(args.Path)
8898
if err != nil {
8999
return nil, nil, err
90100
}
@@ -103,12 +113,16 @@ func (ts *ToolSet) WriteFile(_ context.Context,
103113
return nil, nil, err
104114
}
105115
res := &msi.WriteFileResult{}
106-
return &mcp.CallToolResult{
116+
callToolRes := &mcp.CallToolResult{
107117
// Gemini:
108118
// A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt`
109119
// or `Successfully created and wrote to new file: /path/to/new/file.txt.`
110120
StructuredContent: res,
111-
}, res, nil
121+
}
122+
if warnings != "" {
123+
callToolRes.Meta[MetaWarnings] = warnings
124+
}
125+
return callToolRes, res, nil
112126
}
113127

114128
func (ts *ToolSet) Glob(_ context.Context,
@@ -124,7 +138,7 @@ func (ts *ToolSet) Glob(_ context.Context,
124138
if args.Path != nil && *args.Path != "" {
125139
pathStr = *args.Path
126140
}
127-
guestPath, err := ts.TranslateHostPath(pathStr)
141+
guestPath, warnings, err := ts.TranslateHostPath(pathStr)
128142
if err != nil {
129143
return nil, nil, err
130144
}
@@ -139,11 +153,15 @@ func (ts *ToolSet) Glob(_ context.Context,
139153
res := &msi.GlobResult{
140154
Matches: matches,
141155
}
142-
return &mcp.CallToolResult{
156+
callToolRes := &mcp.CallToolResult{
143157
// Gemini:
144158
// A message like: Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...
145159
StructuredContent: res,
146-
}, res, nil
160+
}
161+
if warnings != "" {
162+
callToolRes.Meta[MetaWarnings] = warnings
163+
}
164+
return callToolRes, res, nil
147165
}
148166

149167
func (ts *ToolSet) SearchFileContent(ctx context.Context,
@@ -159,7 +177,7 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context,
159177
if args.Path != nil && *args.Path != "" {
160178
pathStr = *args.Path
161179
}
162-
guestPath, err := ts.TranslateHostPath(pathStr)
180+
guestPath, warnings, err := ts.TranslateHostPath(pathStr)
163181
if err != nil {
164182
return nil, nil, err
165183
}
@@ -176,9 +194,13 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context,
176194
res := &msi.SearchFileContentResult{
177195
GitGrepOutput: cmdRes.Stdout,
178196
}
179-
return &mcp.CallToolResult{
197+
callToolRes := &mcp.CallToolResult{
180198
// Gemini:
181199
// A message like: Found 10 matching lines for regex "function\\s+myFunction" in directory src:\nsrc/file1.js:10:function myFunction() {...}\nsrc/subdir/file2.ts:45: function myFunction(param) {...}...
182200
StructuredContent: res,
183-
}, res, nil
201+
}
202+
if warnings != "" {
203+
callToolRes.Meta[MetaWarnings] = warnings
204+
}
205+
return callToolRes, res, nil
184206
}

pkg/mcp/toolset/shell.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context,
2121
if ts.inst == nil {
2222
return nil, nil, errors.New("instance not registered")
2323
}
24-
guestPath, err := ts.TranslateHostPath(args.Directory)
24+
guestPath, warnings, err := ts.TranslateHostPath(args.Directory)
2525
if err != nil {
2626
return nil, nil, err
2727
}
@@ -36,6 +36,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context,
3636
Stdout: stdout.String(),
3737
Stderr: stderr.String(),
3838
}
39+
3940
if cmdErr == nil {
4041
res.ExitCode = ptr.Of(0)
4142
} else {
@@ -44,7 +45,11 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context,
4445
res.ExitCode = ptr.Of(st.ExitCode())
4546
}
4647
}
47-
return &mcp.CallToolResult{
48+
callToolRes := &mcp.CallToolResult{
4849
StructuredContent: res,
49-
}, res, nil
50+
}
51+
if warnings != "" {
52+
callToolRes.Meta[MetaWarnings] = warnings
53+
}
54+
return callToolRes, res, nil
5055
}

pkg/mcp/toolset/toolset.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import (
99
"fmt"
1010
"os"
1111
"os/exec"
12+
"path"
1213
"path/filepath"
1314
"slices"
15+
"strings"
1416

1517
"github.com/modelcontextprotocol/go-sdk/mcp"
1618
"github.com/pkg/sftp"
@@ -102,13 +104,38 @@ func (ts *ToolSet) Close() error {
102104
return err
103105
}
104106

105-
func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) {
107+
func (ts *ToolSet) TranslateHostPath(hostPath string) (guestPath, warnings string, err error) {
106108
if hostPath == "" {
107-
return "", errors.New("path is empty")
109+
return "", "", errors.New("path is empty")
108110
}
109-
if !filepath.IsAbs(hostPath) {
110-
return "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath)
111+
if !filepath.IsAbs(hostPath) && !strings.HasPrefix(hostPath, "/") {
112+
return "", "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath)
111113
}
112-
// TODO: make sure that hostPath is mounted
113-
return hostPath, nil
114+
115+
guestPath, isMounted := ts.translateToGuestPath(hostPath)
116+
if !isMounted {
117+
warnings = fmt.Sprintf("path %q is not under any mounted directory, using as guest path", hostPath)
118+
logrus.Info(warnings)
119+
}
120+
return guestPath, warnings, nil
121+
}
122+
123+
func (ts *ToolSet) translateToGuestPath(hostPath string) (string, bool) {
124+
for _, mount := range ts.inst.Config.Mounts {
125+
location := filepath.Clean(mount.Location)
126+
cleanPath := filepath.Clean(hostPath)
127+
128+
if cleanPath == location {
129+
return *mount.MountPoint, true
130+
}
131+
132+
rel, err := filepath.Rel(location, cleanPath)
133+
if err == nil && !strings.HasPrefix(rel, "..") && rel != ".." {
134+
rel = filepath.ToSlash(rel)
135+
guestPath := path.Join(*mount.MountPoint, rel)
136+
return guestPath, true
137+
}
138+
}
139+
140+
return hostPath, false
114141
}

pkg/mcp/toolset/toolset_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package toolset
5+
6+
import (
7+
"testing"
8+
9+
"gotest.tools/v3/assert"
10+
11+
"github.com/lima-vm/lima/v2/pkg/limatype"
12+
)
13+
14+
func TestTranslateHostPath(t *testing.T) {
15+
mountPoint1 := "/mnt/home-user"
16+
mountPoint2 := "/mnt/tmp"
17+
18+
tests := []struct {
19+
name string
20+
hostPath string
21+
toolSet ToolSet
22+
wantGuestPath string
23+
wantWarnings bool
24+
wantErr bool
25+
}{
26+
{
27+
name: "file in mounted directory",
28+
hostPath: "/home/user/documents/file.txt",
29+
toolSet: ToolSet{
30+
inst: &limatype.Instance{
31+
Config: &limatype.LimaYAML{
32+
Mounts: []limatype.Mount{
33+
{Location: "/home/user", MountPoint: &mountPoint1},
34+
},
35+
},
36+
},
37+
},
38+
wantGuestPath: "/mnt/home-user/documents/file.txt",
39+
wantWarnings: false,
40+
wantErr: false,
41+
},
42+
{
43+
name: "path outside mount - fallback to guest path",
44+
hostPath: "/other/path/file.txt",
45+
toolSet: ToolSet{
46+
inst: &limatype.Instance{
47+
Config: &limatype.LimaYAML{
48+
Mounts: []limatype.Mount{
49+
{Location: "/home/user", MountPoint: &mountPoint1},
50+
},
51+
},
52+
},
53+
},
54+
wantGuestPath: "/other/path/file.txt",
55+
wantWarnings: true,
56+
wantErr: false,
57+
},
58+
{
59+
name: "similar prefix but not under mount",
60+
hostPath: "/home/user2/file.txt",
61+
toolSet: ToolSet{
62+
inst: &limatype.Instance{
63+
Config: &limatype.LimaYAML{
64+
Mounts: []limatype.Mount{
65+
{Location: "/home/user", MountPoint: &mountPoint1},
66+
},
67+
},
68+
},
69+
},
70+
wantGuestPath: "/home/user2/file.txt",
71+
wantWarnings: true,
72+
wantErr: false,
73+
},
74+
{
75+
name: "multiple mounts",
76+
hostPath: "/tmp/myfile",
77+
toolSet: ToolSet{
78+
inst: &limatype.Instance{
79+
Config: &limatype.LimaYAML{
80+
Mounts: []limatype.Mount{
81+
{Location: "/home/user", MountPoint: &mountPoint1},
82+
{Location: "/tmp", MountPoint: &mountPoint2},
83+
},
84+
},
85+
},
86+
},
87+
wantGuestPath: "/mnt/tmp/myfile",
88+
wantWarnings: false,
89+
wantErr: false,
90+
},
91+
{
92+
name: "relative path should error",
93+
hostPath: "relative/path",
94+
toolSet: ToolSet{
95+
inst: &limatype.Instance{
96+
Config: &limatype.LimaYAML{
97+
Mounts: []limatype.Mount{
98+
{Location: "/home/user", MountPoint: &mountPoint1},
99+
},
100+
},
101+
},
102+
},
103+
wantGuestPath: "",
104+
wantWarnings: false,
105+
wantErr: true,
106+
},
107+
{
108+
name: "empty path should error",
109+
hostPath: "",
110+
toolSet: ToolSet{
111+
inst: &limatype.Instance{
112+
Config: &limatype.LimaYAML{
113+
Mounts: []limatype.Mount{
114+
{Location: "/home/user", MountPoint: &mountPoint1},
115+
},
116+
},
117+
},
118+
},
119+
wantGuestPath: "",
120+
wantWarnings: false,
121+
wantErr: true,
122+
},
123+
}
124+
125+
for _, test := range tests {
126+
t.Run(test.name, func(t *testing.T) {
127+
got, logs, err := test.toolSet.TranslateHostPath(test.hostPath)
128+
if test.wantErr {
129+
assert.Assert(t, err != nil)
130+
} else {
131+
assert.NilError(t, err)
132+
assert.Equal(t, test.wantGuestPath, got)
133+
if test.wantWarnings {
134+
assert.Assert(t, logs != "")
135+
} else {
136+
assert.Equal(t, "", logs)
137+
}
138+
}
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)