From 505077dbce38d1c3d9c9aa74386044b6e20be93c Mon Sep 17 00:00:00 2001 From: Keith Patrick Date: Sat, 21 Mar 2026 05:02:47 +0000 Subject: [PATCH] Add execline tool for secure command execution - Add pkg/tools/execline.go with ExeclineTool implementation - Update config to support execline-based tool execution - Update agent instance to use execline when configured --- docker/entrypoint.sh | 0 pkg/agent/instance.go | 3 + pkg/config/config.go | 3 + pkg/tools/execline.go | 135 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) mode change 100644 => 100755 docker/entrypoint.sh create mode 100644 pkg/tools/execline.go diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 355e78a33..49fb4d0b9 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -91,6 +91,9 @@ func NewAgentInstance( toolsRegistry.Register(execTool) } } + if cfg.Tools.IsToolEnabled("execline") { + toolsRegistry.Register(tools.NewExeclineTool(cfg)) + } if cfg.Tools.IsToolEnabled("edit_file") { toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) diff --git a/pkg/config/config.go b/pkg/config/config.go index 33a5db8ae..b0df6727a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -784,6 +784,7 @@ type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` + Execline ToolConfig `json:"execline" envPrefix:"PICOCLAW_TOOLS_EXELINE_"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` @@ -1247,6 +1248,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Cron.Enabled case "exec": return t.Exec.Enabled + case "execline": + return t.Execline.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": diff --git a/pkg/tools/execline.go b/pkg/tools/execline.go new file mode 100644 index 000000000..1b5057565 --- /dev/null +++ b/pkg/tools/execline.go @@ -0,0 +1,135 @@ +package tools + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/tools/shell" +) + +// ExeclineTool executes commands using execlineb instead of shell +// Security: execlineb does not support variable expansion ($VAR) or command +// substitution $(cmd) by default, reducing attack surface significantly. +type ExeclineTool struct { + config *config.Config +} + +// NewExeclineTool creates a new ExeclineTool instance +func NewExeclineTool(cfg *config.Config) *ExeclineTool { + return &ExeclineTool{ + config: cfg, + } +} + +// Name returns the name of the tool +func (t *ExeclineTool) Name() string { + return "execline" +} + +// Description returns the tool description +func (t *ExeclineTool) Description() string { + return `Execute commands using execlineb - a secure, minimal shell that does not expand variables or command substitution. Use for simple commands without shell features.` +} + +// Parameters returns the tool parameters +func (t *ExeclineTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The command to execute (passed as-is, no shell expansion)", + }, + }, + "required": []string{"command"}, + } +} + +// Execute runs a command using execlineb +// Commands are executed as-is without shell expansion +func (t *ExeclineTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + // Extract command from args + cmdArg, ok := args["command"] + if !ok { + return &ToolResult{ + ForLLM: "Missing 'command' argument", + IsError: true, + Err: fmt.Errorf("missing 'command' argument"), + } + } + + // Get the command string + command, ok := cmdArg.(string) + if !ok { + return &ToolResult{ + ForLLM: "'command' must be a string", + IsError: true, + Err: fmt.Errorf("'command' must be a string"), + } + } + + if command == "" { + return &ToolResult{ + ForLLM: "Empty command", + IsError: true, + Err: fmt.Errorf("empty command"), + } + } + + // Check for dangerous patterns in the command + if err := t.validateCommand(command); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("Validation error: %v", err), + IsError: true, + Err: fmt.Errorf("validation error: %w", err), + } + } + + // Build safe environment + baseEnv := shell.WithAllowedEnv(nil, nil) + var extraEnv map[string]string + if t.config != nil { + extraEnv = t.config.Tools.Exec.EnvSet + } + execEnv := shell.MergeEnvVars(baseEnv, nil, extraEnv) + + // Use execlineb to execute + // execlineb -c takes a command string and executes it + // Unlike sh -c, it doesn't expand $VAR or $(cmd) + cmd := exec.CommandContext(ctx, "/usr/bin/execlineb", "-c", command) + cmd.Env = shell.MapToEnvSlice(execEnv) + + output, err := cmd.CombinedOutput() + if err != nil { + return &ToolResult{ + ForLLM: string(output), + ForUser: string(output), + IsError: true, + Err: fmt.Errorf("execlineb error: %w", err), + } + } + + return &ToolResult{ + ForLLM: string(output), + ForUser: string(output), + IsError: false, + } +} + +// validateCommand checks for dangerous command patterns +// Since execline is secure by default, we only block obvious exploits +func (t *ExeclineTool) validateCommand(cmd string) error { + // Block obvious shell escape attempts + if strings.Contains(cmd, "&&") || strings.Contains(cmd, "||") { + return fmt.Errorf("control operators (&&, ||) not supported in execline") + } + if strings.Contains(cmd, "|") && strings.Contains(cmd, "sh") { + return fmt.Errorf("pipe to shell detected") + } + // Note: $VAR and $(cmd) are simply not expanded by execlineb + // They are passed literally to the command, so this is safe + return nil +}