Skip to content

Commit 7c72ecd

Browse files
Whxuan0701Whxuan0701EItanya
authored
fix(agentplugins): reject unsafe and conflicting skill selections (#2505)
## Summary - reject skill selections that are empty, traversal-like, or contain path separators - reject duplicate skill names across standalone skills and selected plugin skills before fetching artifacts - canonicalize existing and not-yet-created paths during containment checks so macOS `/var` aliases do not reject valid packages This keeps Agent Plugin materialization deterministic and prevents one selected skill from overwriting another. It does not change the Agent Plugins schema or artifact source formats. ## Testing - `cd go && go test ./core/v2/agentplugins ./adk/pkg/mcp ./core/v2/translator` - `cd go && go vet ./core/v2/agentplugins` - `git diff --check` ## Risk / Notes - The new validation runs before any artifact is fetched or copied. - Existing plugin and MCP behavior is unchanged for valid selections. - The canonical-path handling preserves the existing symlink escape checks while making containment portable across macOS temporary-directory aliases. Signed-off-by: Whxuan0701 <102815982+Whxuan0701@users.noreply.github.com> Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io> Co-authored-by: Whxuan0701 <102815982+Whxuan0701@users.noreply.github.com> Co-authored-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent 89123fb commit 7c72ecd

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

go/core/v2/agentplugins/materialize.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ func MaterializeAgentConfig(ctx context.Context, config *adk.AgentConfig, paths
6868
}
6969

7070
func Materialize(ctx context.Context, config adk.AgentPluginConfig, paths Paths) (MCPConfig, error) {
71+
selectedSkills := make([]string, 0, len(config.Skills))
72+
for _, skill := range config.Skills {
73+
selectedSkills = append(selectedSkills, skill.Name)
74+
}
75+
for _, plugin := range config.Plugins {
76+
selectedSkills = append(selectedSkills, plugin.Skills...)
77+
}
78+
if err := validateSkillSelections(selectedSkills); err != nil {
79+
return MCPConfig{}, err
80+
}
81+
7182
if err := os.MkdirAll(paths.Skills, 0o755); err != nil {
7283
return MCPConfig{}, fmt.Errorf("create skills directory: %w", err)
7384
}
@@ -119,6 +130,27 @@ func Materialize(ctx context.Context, config adk.AgentPluginConfig, paths Paths)
119130
return result, nil
120131
}
121132

133+
func validateSkillSelections(names []string) error {
134+
seen := make(map[string]struct{}, len(names))
135+
for _, name := range names {
136+
if err := validateSkillName(name); err != nil {
137+
return err
138+
}
139+
if _, exists := seen[name]; exists {
140+
return fmt.Errorf("duplicate skill name %q", name)
141+
}
142+
seen[name] = struct{}{}
143+
}
144+
return nil
145+
}
146+
147+
func validateSkillName(name string) error {
148+
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) {
149+
return fmt.Errorf("skill name %q must be a single relative path component", name)
150+
}
151+
return nil
152+
}
153+
122154
func fetchSource(ctx context.Context, source adk.AgentPluginSource, destination, requiredFile string) (string, error) {
123155
selected := 0
124156
if source.OCI != "" {
@@ -241,10 +273,24 @@ func containedPath(root, relative string) (string, error) {
241273
}
242274

243275
func pathWithin(root, path string) bool {
244-
relative, err := filepath.Rel(root, path)
276+
root = canonicalPath(root)
277+
path = canonicalPath(path)
278+
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
245279
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
246280
}
247281

282+
func canonicalPath(path string) string {
283+
path = filepath.Clean(path)
284+
if resolved, err := filepath.EvalSymlinks(path); err == nil {
285+
return resolved
286+
}
287+
parent := filepath.Dir(path)
288+
if parent == path {
289+
return path
290+
}
291+
return filepath.Join(canonicalPath(parent), filepath.Base(path))
292+
}
293+
248294
func copySkill(source, destination string) error {
249295
info, err := os.Stat(filepath.Join(source, "SKILL.md"))
250296
if err != nil || !info.Mode().IsRegular() {

go/core/v2/agentplugins/materialize_test.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func TestParseMCPServerSupportsLocalAndRemoteTransports(t *testing.T) {
157157
if err != nil {
158158
t.Fatal(err)
159159
}
160-
if stdio.Command != command || stdio.Args[0] != "--root="+root || stdio.Env["STATE"] != filepath.Join(data, "state") || stdio.CWD != filepath.Join(data, "work") {
160+
if stdio.Command != canonicalPath(command) || stdio.Args[0] != "--root="+root || stdio.Env["STATE"] != filepath.Join(data, "state") || stdio.CWD != filepath.Join(data, "work") {
161161
t.Fatalf("stdio server = %#v", stdio)
162162
}
163163

@@ -230,3 +230,34 @@ func TestCopySkillRejectsSymlinkOutsideSkill(t *testing.T) {
230230
t.Fatal("copySkill() accepted a symlink outside the skill root")
231231
}
232232
}
233+
234+
func TestValidateSkillNameRejectsPathTraversal(t *testing.T) {
235+
for _, name := range []string{"../escape", "nested/skill", `nested\skill`, ".", ".."} {
236+
if err := validateSkillName(name); err == nil {
237+
t.Fatalf("validateSkillName(%q) accepted a path-like name", name)
238+
}
239+
}
240+
}
241+
242+
func TestValidateSkillSelectionsRejectsDuplicateNames(t *testing.T) {
243+
err := validateSkillSelections([]string{"review", "lint", "review"})
244+
if err == nil || !strings.Contains(err.Error(), `duplicate skill name "review"`) {
245+
t.Fatalf("validateSkillSelections() error = %v, want duplicate skill error", err)
246+
}
247+
}
248+
249+
func TestPathWithinCanonicalizesRootAliases(t *testing.T) {
250+
actualRoot := t.TempDir()
251+
child := filepath.Join(actualRoot, "child")
252+
if err := os.Mkdir(child, 0o755); err != nil {
253+
t.Fatal(err)
254+
}
255+
alias := filepath.Join(t.TempDir(), "root-alias")
256+
if err := os.Symlink(actualRoot, alias); err != nil {
257+
t.Fatal(err)
258+
}
259+
260+
if !pathWithin(alias, child) {
261+
t.Fatalf("pathWithin(%q, %q) rejected a path under the aliased root", alias, child)
262+
}
263+
}

0 commit comments

Comments
 (0)