331 lines
9.2 KiB
Go
331 lines
9.2 KiB
Go
package execute
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"ollie/internal/sandbox"
|
|
"ollie/pkg/tools"
|
|
)
|
|
|
|
// Server runs code in a sandboxed environment.
|
|
type Server struct {
|
|
// Confirm is an optional function called before executing sensitive operations.
|
|
// If it returns false, the operation is denied.
|
|
Confirm func(string) bool
|
|
|
|
// cwd is the working directory for sandboxed commands. If empty,
|
|
// the process working directory is used.
|
|
wdMu sync.RWMutex
|
|
cwd string
|
|
|
|
// envExtra holds per-session environment variables injected via SetEnv.
|
|
envMu sync.RWMutex
|
|
envExtra map[string]string
|
|
|
|
// rate limiting state (per-Server)
|
|
rateLimitMu sync.Mutex
|
|
validationFailures int
|
|
lastFailure time.Time
|
|
blockedUntil time.Time
|
|
}
|
|
|
|
// Decl returns a factory for an execute Server with the given working directory.
|
|
func Decl(cwd string) func() tools.Server {
|
|
return func() tools.Server { return New(cwd) }
|
|
}
|
|
|
|
// ListTools implements tools.Server, returning the ToolInfo definitions for the execute_* built-in tools.
|
|
func (e *Server) ListTools() ([]tools.ToolInfo, error) {
|
|
return []tools.ToolInfo{
|
|
{
|
|
Name: "execute_code",
|
|
Description: `Run inline shell code in a sandboxed environment.
|
|
|
|
Usage:
|
|
- Run shell commands: grep, cat, sed, find, etc.
|
|
- Default timeout: 30 seconds
|
|
- Default language: bash
|
|
- Output includes stdout, stderr, and exit code
|
|
|
|
Security:
|
|
- Dangerous commands blocked: rm -rf, fork bombs, sudo, etc.
|
|
- File system access limited to sandbox
|
|
- Network access restricted
|
|
|
|
Examples:
|
|
- List files: code='ls -la'
|
|
- Search content: code='grep -r "TODO" ./src/'
|
|
- Count lines: code='wc -l file.txt'`,
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["code"],
|
|
"properties": {
|
|
"code": {"type": "string", "description": "Code to execute."},
|
|
"language": {"type": "string", "description": "Language interpreter (default: bash)."},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "execute_tool",
|
|
Description: `Run a named tool script from the tools directory.
|
|
|
|
Usage:
|
|
- Scripts located in: ollie/t (default: $HOME/mnt/ollie/t)
|
|
- Supported languages: bash, python3, perl, awk, sed, ed, jq, expect, bc, lua (detected from shebang)
|
|
- Use for named scripts, not inline shell commands
|
|
- Default timeout: 30 seconds
|
|
|
|
Tool Discovery:
|
|
- List tools: execute_code with 'ls ollie/t'
|
|
- Check script permissions before execution
|
|
|
|
Examples:
|
|
- Run bash tool: tool='script.sh', args=['arg1', 'arg2']
|
|
- Run python tool: tool='process.py', args=['--input', 'data.txt']`,
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["tool"],
|
|
"properties": {
|
|
"tool": {"type": "string", "description": "Name of the tool script in the tools directory."},
|
|
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "execute_pipe",
|
|
Description: `Run a pipeline of commands, chaining stdout to stdin.
|
|
|
|
Usage:
|
|
- Pipe output between multiple commands
|
|
- Each step: {code: "cmd"} or {tool: "name", args: [...]}
|
|
- Default timeout: 30 seconds per pipeline
|
|
- Steps execute sequentially
|
|
|
|
Structure:
|
|
- pipe: array of step objects
|
|
- Step with code: shell command string
|
|
- Step with tool: named script from tools directory
|
|
- Use code for shell commands, tool for scripts
|
|
|
|
Examples:
|
|
- Filter and count: pipe=[{code: "grep error log.txt"}, {code: "wc -l"}]
|
|
- Process with script: pipe=[{tool: "parse.py", args: ["data.json"]}, {code: "jq .result"}]`,
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["pipe"],
|
|
"properties": {
|
|
"pipe": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"tool": {"type": "string"},
|
|
"args": {"type": "array", "items": {"type": "string"}},
|
|
"code": {"type": "string"}
|
|
}
|
|
}
|
|
},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// CallTool implements tools.Server.
|
|
func (e *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
result, err := e.Dispatch(ctx, tool, args)
|
|
if err != nil {
|
|
return json.Marshal(map[string]string{"error": err.Error()})
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"content": []map[string]string{{"type": "text", "text": result}},
|
|
})
|
|
}
|
|
|
|
// New creates a new Server with the given working directory.
|
|
func New(cwd string) *Server { return &Server{cwd: cwd} }
|
|
|
|
// SetCWD updates the working directory used for subsequent command executions.
|
|
func (e *Server) SetCWD(dir string) {
|
|
e.wdMu.Lock()
|
|
e.cwd = dir
|
|
e.wdMu.Unlock()
|
|
}
|
|
|
|
// SetEnv adds a per-session environment variable to all subsequent commands.
|
|
func (e *Server) SetEnv(key, value string) {
|
|
e.envMu.Lock()
|
|
if e.envExtra == nil {
|
|
e.envExtra = make(map[string]string)
|
|
}
|
|
e.envExtra[key] = value
|
|
e.envMu.Unlock()
|
|
}
|
|
|
|
var whitespacePattern = regexp.MustCompile(`\s+`)
|
|
|
|
const (
|
|
maxFailures = 5
|
|
blockDuration = 30 * time.Second
|
|
failureWindow = 60 * time.Second
|
|
)
|
|
|
|
func (e *Server) checkRateLimit() error {
|
|
e.rateLimitMu.Lock()
|
|
defer e.rateLimitMu.Unlock()
|
|
|
|
now := time.Now()
|
|
if now.Before(e.blockedUntil) {
|
|
remaining := e.blockedUntil.Sub(now).Round(time.Second)
|
|
return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Server) recordValidationFailure() {
|
|
e.rateLimitMu.Lock()
|
|
defer e.rateLimitMu.Unlock()
|
|
|
|
now := time.Now()
|
|
if now.Sub(e.lastFailure) > failureWindow {
|
|
e.validationFailures = 0
|
|
}
|
|
|
|
e.validationFailures++
|
|
e.lastFailure = now
|
|
|
|
if e.validationFailures >= maxFailures {
|
|
e.blockedUntil = now.Add(blockDuration)
|
|
e.validationFailures = 0
|
|
}
|
|
}
|
|
|
|
// Execute runs code in a sandbox and returns combined stdout+stderr.
|
|
func (e *Server) Execute(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool) (string, error) {
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
|
|
// Domain-specific tools (awk, sed, jq, ed, bc, expect) skip validation —
|
|
// they are constrained by design and the sandbox handles OS-level restrictions.
|
|
// General-purpose languages get universal + language-specific pattern checks.
|
|
_, isDomainSpecific := map[string]struct{}{
|
|
"awk": {}, "sed": {}, "jq": {}, "ed": {}, "bc": {}, "expect": {},
|
|
}[language]
|
|
if !trusted && !isDomainSpecific {
|
|
if err := e.ValidateCode(code, language); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
cfg, err := loadLayeredConfig(sandboxName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
e.wdMu.RLock()
|
|
workDir := e.cwd
|
|
e.wdMu.RUnlock()
|
|
if workDir == "" {
|
|
workDir, _ = os.Getwd()
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
var cmd *exec.Cmd
|
|
var interpreter []string
|
|
var stdin string // non-empty: pipe this to cmd.Stdin instead of embedding in args
|
|
switch language {
|
|
case "bash", "":
|
|
interpreter = []string{"bash", "-c", code}
|
|
case "python3", "python":
|
|
interpreter = []string{"python3", "-c", code}
|
|
case "perl":
|
|
interpreter = []string{"perl", "-e", code}
|
|
case "lua":
|
|
interpreter = []string{"lua", "-e", code}
|
|
case "awk":
|
|
interpreter = []string{"gawk", code}
|
|
case "sed":
|
|
interpreter = []string{"sed", "-e", code}
|
|
case "jq":
|
|
interpreter = []string{"jq", code}
|
|
case "ed":
|
|
interpreter = []string{"ed", "-s"}
|
|
stdin = code
|
|
case "expect":
|
|
interpreter = []string{"expect", "-"}
|
|
stdin = code
|
|
case "bc":
|
|
interpreter = []string{"bc", "-ql"}
|
|
stdin = code
|
|
default:
|
|
return "", fmt.Errorf("unsupported language: %s (supported: bash, python3, perl, awk, sed, ed, jq, expect, bc, lua)", language)
|
|
}
|
|
wrapped := sandbox.WrapCommand(cfg, interpreter, workDir)
|
|
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
|
|
cmd.Dir = workDir
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
|
|
// Inject per-session env vars so they're visible inside the sandbox.
|
|
e.envMu.RLock()
|
|
if len(e.envExtra) > 0 {
|
|
cmd.Env = os.Environ()
|
|
for k, v := range e.envExtra {
|
|
cmd.Env = append(cmd.Env, k+"="+v)
|
|
}
|
|
}
|
|
e.envMu.RUnlock()
|
|
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process != nil {
|
|
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var outputBuf bytes.Buffer
|
|
lw := &limitedWriter{w: &outputBuf, limit: 10 * 1024 * 1024}
|
|
cmd.Stdout = lw
|
|
cmd.Stderr = lw
|
|
|
|
err = cmd.Run()
|
|
output := outputBuf.Bytes()
|
|
if lw.truncated {
|
|
output = append(output, []byte("\n[output truncated at 10MB]")...)
|
|
}
|
|
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
|
|
}
|
|
if err != nil {
|
|
return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output))
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
// Close implements tools.Server (no-op).
|
|
func (e *Server) Close() {}
|
|
|
|
var _ tools.Server = (*Server)(nil) // compile-time interface check
|