diff --git a/README.md b/README.md index cce4cbb..7331dca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sidekick -Sidekick is a versatile agentic framework written in Go 1.25. It provides a robust architecture for building AI assistants with hierarchical orchestration, native Model Context Protocol (MCP) integration, and a rich terminal user interface (TUI). +Sidekick is a simple but versatile agentic framework written in Go 1.25. It provides a robust architecture for building AI assistants with hierarchical orchestration, native Model Context Protocol (MCP) integration, and a rich terminal user interface (TUI). ## Features @@ -9,8 +9,35 @@ Sidekick is a versatile agentic framework written in Go 1.25. It provides a robu * **Intelligent Buffering:** Automatically buffers large tool outputs to prevent context window exhaustion, returning file pointers for significant payloads. * **Rich Terminal UI:** A beautiful, responsive TUI built with Charmbracelet's BubbleTea and Lipgloss, featuring status bars, role-based formatting, and dynamic viewports. * **Advanced Prompt Templating:** Define complex, multi-line system prompts using TOML. Support for Go's `text/template` engine allows you to compose modular prompts from reusable snippets. +* **Internal Tools:** Built-in tools for file I/O (`read_file`, `write_file`, `grep_file`, `edit_file`) and an optional, secure `shell_exec` tool for running POSIX shell commands. * **Configuration Driven:** Easily configure models, agents, tools, and MCP servers via a declarative `config.toml` file. +## Internal Tools + +Sidekick includes a set of internal tools that are always available to agents. These tools provide common file operations and system access. + +### File Tools +- `read_file`: Read a file with optional line windowing. +- `write_file`: Overwrite a file with new content. +- `grep_file`: Regex search with surrounding context. +- `edit_file`: Replace a string or block of lines in a file. + +### shell_exec Tool + +The `shell_exec` tool allows an agent to run arbitrary POSIX shell commands and receive combined stdout/stderr output. + +**Security Warning:** This tool is extremely powerful and potentially dangerous. It is **disabled by default** and must be explicitly enabled for each agent in `config.toml`. + +To enable `shell_exec` for an agent, add `enable_shell_exec = true` to its configuration: + +```toml +[agents.coordinator] +role = "COORDINATOR" +model_id = "default" +enable_shell_exec = true # Security: explicitly enabled +toolsets = ["filesystem"] +``` + ## Installation ### Prerequisites @@ -181,4 +208,4 @@ func main() { ## Credits -Created by Luxferre in 2026, released into public domain with no warranties. +Created by Luxferre in 2026, released into the public domain with no warranties. diff --git a/agent.go b/agent.go index dba720e..6cb88c0 100644 --- a/agent.go +++ b/agent.go @@ -24,6 +24,9 @@ func NewSidekick(config AgentConfig, model ModelConfig, extTools []ToolDefinitio ToolRegistry: make(map[string]ToolDefinition), } for name, tool := range DefaultInternalTools() { + if name == "shell_exec" && !s.Config.EnableShellExec { + continue + } s.registerTool(name, tool) } for _, tool := range extTools { diff --git a/config.go b/config.go index d0926dd..b1f3ed6 100644 --- a/config.go +++ b/config.go @@ -56,6 +56,7 @@ type AgentConfig struct { MaxIterations int `toml:"max_iterations"` Toolsets []string `toml:"toolsets"` // Allowed tool namespaces (MCP servers) Subagents []string `toml:"subagents"` // Allowed subagent IDs + EnableShellExec bool `toml:"enable_shell_exec"` ToolResponseThreshold int `toml:"-"` } diff --git a/internal_tools.go b/internal_tools.go index 0105474..edd4c98 100644 --- a/internal_tools.go +++ b/internal_tools.go @@ -3,6 +3,7 @@ package sidekick import ( "fmt" "os" + "os/exec" "regexp" "strings" ) @@ -69,9 +70,43 @@ func DefaultInternalTools() map[string]ToolDefinition { Internal: true, Handler: editFileHandler, }, + "shell_exec": { + Name: "shell_exec", + Description: "Run a POSIX shell command and return its combined stdout and stderr.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string"}, + }, + "required": []string{"command"}, + }, + Internal: true, + Handler: shellExecHandler, + }, } } +func shellExecHandler(args map[string]interface{}) (string, error) { + cmdStr, ok := args["command"].(string) + if !ok { + return "", fmt.Errorf("command is required") + } + + cmd := exec.Command("sh", "-c", cmdStr) + output, err := cmd.CombinedOutput() + + result := string(output) + if err != nil { + result = fmt.Sprintf("%s\nError: %v", result, err) + } + + if result == "" { + return "(empty output)", nil + } + + return result, nil +} + func readFileHandler(args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { diff --git a/internal_tools_test.go b/internal_tools_test.go index 6b925c4..378b2c9 100644 --- a/internal_tools_test.go +++ b/internal_tools_test.go @@ -6,6 +6,23 @@ import ( "testing" ) +func TestShellExecHandler(t *testing.T) { + // Success + got, err := shellExecHandler(map[string]interface{}{"command": "echo hello"}) + if err != nil { t.Fatal(err) } + if strings.TrimSpace(got) != "hello" { t.Errorf("expected hello, got %s", got) } + + // Failure + got, err = shellExecHandler(map[string]interface{}{"command": "ls /nonexistent-file-path-that-should-not-exist"}) + if err != nil { t.Fatal(err) } + if !strings.Contains(got, "Error:") { t.Errorf("expected error in output, got %s", got) } + + // Empty + got, err = shellExecHandler(map[string]interface{}{"command": "true"}) + if err != nil { t.Fatal(err) } + if got != "(empty output)" { t.Errorf("expected empty output, got %s", got) } +} + func TestHashLine(t *testing.T) { line := "hello world" h1 := hashLine(line) diff --git a/sidekick_test.go b/sidekick_test.go index a2e07dd..e407e4b 100644 --- a/sidekick_test.go +++ b/sidekick_test.go @@ -79,6 +79,22 @@ func TestDelegation(t *testing.T) { if ans != "Coord done" { t.Errorf("expected Coord done, got %s", ans) } } +func TestShellExecRegistration(t *testing.T) { + // Scenario 1: Disabled (default) + c1 := AgentConfig{ID: "a1", EnableShellExec: false} + a1 := NewSidekick(c1, ModelConfig{}, nil) + if _, ok := a1.ToolRegistry["shell_exec"]; ok { + t.Errorf("shell_exec should be disabled by default") + } + + // Scenario 2: Enabled + c2 := AgentConfig{ID: "a2", EnableShellExec: true} + a2 := NewSidekick(c2, ModelConfig{}, nil) + if _, ok := a2.ToolRegistry["shell_exec"]; !ok { + t.Errorf("shell_exec should be enabled when EnableShellExec is true") + } +} + func TestTempFileCleanup(t *testing.T) { threshold := 10 a := NewSidekick(AgentConfig{MaxIterations: 2, ToolResponseThreshold: threshold}, ModelConfig{}, nil)