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
This commit is contained in:
Keith Patrick 2026-03-21 05:02:47 +00:00
parent 71ce21963b
commit 505077dbce
4 changed files with 141 additions and 0 deletions

0
docker/entrypoint.sh Normal file → Executable file
View file

View file

@ -91,6 +91,9 @@ func NewAgentInstance(
toolsRegistry.Register(execTool) toolsRegistry.Register(execTool)
} }
} }
if cfg.Tools.IsToolEnabled("execline") {
toolsRegistry.Register(tools.NewExeclineTool(cfg))
}
if cfg.Tools.IsToolEnabled("edit_file") { if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))

View file

@ -784,6 +784,7 @@ type ToolsConfig struct {
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Execline ToolConfig `json:"execline" envPrefix:"PICOCLAW_TOOLS_EXELINE_"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"` MCP MCPConfig `json:"mcp"`
@ -1247,6 +1248,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.Cron.Enabled return t.Cron.Enabled
case "exec": case "exec":
return t.Exec.Enabled return t.Exec.Enabled
case "execline":
return t.Execline.Enabled
case "skills": case "skills":
return t.Skills.Enabled return t.Skills.Enabled
case "media_cleanup": case "media_cleanup":

135
pkg/tools/execline.go Normal file
View file

@ -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
}