-
Notifications
You must be signed in to change notification settings - Fork 322
Support dynamic command expansion in skills (\!command syntax)
#2116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package skills | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "os/exec" | ||
| "regexp" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| // commandTimeout is the maximum time allowed for a single command expansion. | ||
| const commandTimeout = 30 * time.Second | ||
|
|
||
| // maxOutputSize is the maximum number of bytes read from a command's stdout. | ||
| const maxOutputSize = 1 << 20 // 1 MB | ||
|
|
||
| // commandPattern matches the !`command` syntax used by Claude Code skills | ||
| // to embed dynamic command output into skill content. | ||
| var commandPattern = regexp.MustCompile("!`([^`]+)`") | ||
|
|
||
| // ExpandCommands replaces all !`command` patterns in the given content | ||
| // with the stdout of executing each command via the system shell. | ||
| // Commands are executed with the specified working directory. | ||
| // If a command fails, the pattern is replaced with an error message | ||
| // rather than failing the entire expansion. | ||
| func ExpandCommands(ctx context.Context, content, workDir string) string { | ||
| return commandPattern.ReplaceAllStringFunc(content, func(match string) string { | ||
| command := match[2 : len(match)-1] // strip leading !` and trailing ` | ||
|
|
||
| output, err := runCommand(ctx, command, workDir) | ||
| if err != nil { | ||
| slog.Warn("Skill command expansion failed", "command", command, "error", err) | ||
| return fmt.Sprintf("[error executing `%s`: %s]", command, err) | ||
| } | ||
|
|
||
| return strings.TrimRight(output, "\n") | ||
| }) | ||
| } | ||
|
|
||
| // runCommand executes a shell command and returns its stdout (up to maxOutputSize bytes). | ||
| // The command runs in the specified working directory. | ||
| func runCommand(ctx context.Context, command, workDir string) (string, error) { | ||
| ctx, cancel := context.WithTimeout(ctx, commandTimeout) | ||
| defer cancel() | ||
|
|
||
| cmd := exec.CommandContext(ctx, "sh", "-c", command) | ||
| cmd.Dir = workDir | ||
|
|
||
| var stderr bytes.Buffer | ||
| cmd.Stderr = &stderr | ||
|
|
||
| stdout, err := cmd.StdoutPipe() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| if err := cmd.Start(); err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| out, err := io.ReadAll(io.LimitReader(stdout, maxOutputSize)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If Issue: Recommendation: out, err := io.ReadAll(io.LimitReader(stdout, maxOutputSize))
if err != nil {
cmd.Process.Kill()
cmd.Wait() // Clean up the process
return "", err
}Or use |
||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| // Drain any remaining stdout so the process doesn't block on a full pipe | ||
| // and hang until the context timeout kills it. | ||
| _, _ = io.Copy(io.Discard, stdout) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After reading Issue: Recommendation: if ctx.Err() != nil {
cmd.Process.Kill()
return "", ctx.Err()
}
_, _ = io.Copy(io.Discard, stdout) |
||
|
|
||
| if err := cmd.Wait(); err != nil { | ||
| if ctx.Err() == context.DeadlineExceeded { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code checks Issue: Recommendation: if err := cmd.Wait(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return "", fmt.Errorf("command timed out after %s", commandTimeout)
}
// ... rest of error handling
} |
||
| return "", fmt.Errorf("command timed out after %s", commandTimeout) | ||
| } | ||
| if stderrMsg := strings.TrimSpace(stderr.String()); stderrMsg != "" { | ||
| return "", fmt.Errorf("%w: %s", err, stderrMsg) | ||
| } | ||
| return "", err | ||
| } | ||
|
|
||
| return string(out), nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| package skills | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func skipOnWindows(t *testing.T) { | ||
| t.Helper() | ||
| if runtime.GOOS == "windows" { | ||
| t.Skip("skipping on windows") | ||
| } | ||
| } | ||
|
|
||
| func TestExpandCommands(t *testing.T) { | ||
| skipOnWindows(t) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| content string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "no patterns", | ||
| content: "# My Skill\n\nJust regular markdown content.", | ||
| want: "# My Skill\n\nJust regular markdown content.", | ||
| }, | ||
| { | ||
| name: "simple echo", | ||
| content: "Hello !`echo world`!", | ||
| want: "Hello world!", | ||
| }, | ||
| { | ||
| name: "multiple commands", | ||
| content: "Name: !`echo alice`, Age: !`echo 30`", | ||
| want: "Name: alice, Age: 30", | ||
| }, | ||
| { | ||
| name: "multiline output", | ||
| content: "Files:\n!`printf 'a.go\nb.go\nc.go\n'`\nEnd.", | ||
| want: "Files:\na.go\nb.go\nc.go\nEnd.", | ||
| }, | ||
| { | ||
| name: "empty output", | ||
| content: "Before !`true` after", | ||
| want: "Before after", | ||
| }, | ||
| { | ||
| name: "pipes", | ||
| content: "Count: !`printf 'a\nb\nc\n' | wc -l | tr -d ' '`", | ||
| want: "Count: 3", | ||
| }, | ||
| { | ||
| name: "preserves regular backticks", | ||
| content: "Use `echo hello` to print.\n\nCode: ```go\nfmt.Println()\n```", | ||
| want: "Use `echo hello` to print.\n\nCode: ```go\nfmt.Println()\n```", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := ExpandCommands(t.Context(), tt.content, t.TempDir()) | ||
| assert.Equal(t, tt.want, result) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestExpandCommands_WorkingDirectory(t *testing.T) { | ||
| skipOnWindows(t) | ||
|
|
||
| tmpDir := t.TempDir() | ||
| require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello"), 0o644)) | ||
|
|
||
| result := ExpandCommands(t.Context(), "Content: !`cat test.txt`", tmpDir) | ||
| assert.Equal(t, "Content: hello", result) | ||
| } | ||
|
|
||
| func TestExpandCommands_ScriptExecution(t *testing.T) { | ||
| skipOnWindows(t) | ||
|
|
||
| tmpDir := t.TempDir() | ||
| require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "info.sh"), []byte("#!/bin/sh\necho from-script"), 0o755)) | ||
|
|
||
| result := ExpandCommands(t.Context(), "Output: !`./info.sh`", tmpDir) | ||
| assert.Equal(t, "Output: from-script", result) | ||
| } | ||
|
|
||
| func TestExpandCommands_FailedCommand(t *testing.T) { | ||
| skipOnWindows(t) | ||
|
|
||
| result := ExpandCommands(t.Context(), "Before !`nonexistent_command_12345` after", t.TempDir()) | ||
| assert.Contains(t, result, "Before ") | ||
| assert.Contains(t, result, "[error executing `nonexistent_command_12345`:") | ||
| assert.Contains(t, result, " after") | ||
| } | ||
|
|
||
| func TestExpandCommands_CancelledContext(t *testing.T) { | ||
| skipOnWindows(t) | ||
|
|
||
| ctx, cancel := context.WithCancel(t.Context()) | ||
| cancel() | ||
|
|
||
| result := ExpandCommands(ctx, "Result: !`echo hello`", t.TempDir()) | ||
| assert.Contains(t, result, "[error executing `echo hello`:") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ type Skill struct { | |
| FilePath string `yaml:"-"` | ||
| BaseDir string `yaml:"-"` | ||
| Files []string `yaml:"-"` | ||
| Local bool `yaml:"-"` // true for filesystem-loaded skills, false for remote | ||
| License string `yaml:"license"` | ||
| Compatibility string `yaml:"compatibility"` | ||
| Metadata map[string]string `yaml:"metadata"` | ||
|
|
@@ -308,6 +309,7 @@ func loadSkillFile(path, dirName string) (Skill, bool) { | |
| skill.Name = cmp.Or(skill.Name, dirName) | ||
| skill.FilePath = path | ||
| skill.BaseDir = filepath.Dir(path) | ||
| skill.Local = true | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL: No Cryptographic Verification of Local Skills All filesystem-loaded skills are marked Security Risk:
Recommendation:
|
||
|
|
||
| return skill, true | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -218,7 +218,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c | |
| if agentConfig.Skills.Enabled() { | ||
| loadedSkills := skills.Load(agentConfig.Skills.Sources) | ||
| if len(loadedSkills) > 0 { | ||
| agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills)) | ||
| agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills, runConfig.WorkingDir)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL: User-Controlled Working Directory The Security Risk:
Recommendation:
|
||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 CRITICAL: Arbitrary Command Execution Without Sandboxing
The
runCommandfunction executes arbitrary shell commands from SKILL.md files usingexec.CommandContextwithsh -c. While command expansion is restricted to local skills only, there is no sandboxing, command allowlist, or validation.Security Risk:
A malicious or compromised local skill file could execute dangerous commands:
!\- filesystem destructionThese commands would execute with full docker-agent process privileges.
Recommendation:
--allow-skill-commandsflag