Files
goclaw/internal/tools/dynamic_tool.go
T
viettranx 843b550651 feat: runtime packages UI, pkg-helper, configurable shell deny groups (#244)
Runtime package management with security hardening:

- pkg-helper: root-privileged daemon for apk install/uninstall via Unix socket
- HTTP API: /v1/packages (list/install/uninstall/runtimes), admin role required for writes
- Shell deny groups: 15 configurable groups (per-agent overrides via context)
- Packages UI: Web page for managing system/pip/npm packages with confirmation dialogs
- Docker: privilege separation (root entrypoint → su-exec drop), init for zombie reaping
- Security: umask socket creation, persist file validation, deny pattern hardening
  (Node.js fetch/http, Python from/import, curl localhost, sensitive env vars)
- Auth: empty gateway token → admin role (dev/single-user mode)
2026-03-17 19:50:26 +07:00

134 lines
3.5 KiB
Go

package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// DynamicTool wraps a CustomToolDef from the database and implements the Tool interface.
// Command templates use {{.key}} placeholders — all LLM-provided values are shell-escaped.
type DynamicTool struct {
def store.CustomToolDef
workspace string
params map[string]any
}
// NewDynamicTool creates a Tool from a DB-stored custom tool definition.
func NewDynamicTool(def store.CustomToolDef, workspace string) *DynamicTool {
var params map[string]any
if len(def.Parameters) > 0 {
json.Unmarshal(def.Parameters, &params)
}
if params == nil {
params = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return &DynamicTool{def: def, workspace: workspace, params: params}
}
func (t *DynamicTool) Name() string { return t.def.Name }
func (t *DynamicTool) Description() string { return t.def.Description }
func (t *DynamicTool) Parameters() map[string]any { return t.params }
func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result {
// Render command template with shell-escaped args
command := renderCommand(t.def.Command, args)
// Check deny patterns (uses all defaults for dynamic tools — no per-agent override)
for _, pattern := range DefaultDenyPatterns() {
if pattern.MatchString(command) {
return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String()))
}
}
// Timeout
timeout := time.Duration(t.def.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 60 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Working directory — per-user workspace from context, fallback to tool's workspace.
// Explicit WorkingDir on the tool definition still overrides everything.
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if t.def.WorkingDir != "" {
cwd = t.def.WorkingDir
}
cmd := exec.CommandContext(ctx, "sh", "-c", command)
cmd.Dir = cwd
// Decrypt and apply env vars
if len(t.def.Env) > 0 {
var envMap map[string]string
if json.Unmarshal(t.def.Env, &envMap) == nil {
for k, v := range envMap {
cmd.Env = append(cmd.Env, k+"="+v)
}
}
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var result string
if stdout.Len() > 0 {
result = stdout.String()
}
if stderr.Len() > 0 {
if result != "" {
result += "\n"
}
result += "STDERR:\n" + stderr.String()
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return ErrorResult(fmt.Sprintf("command timed out after %s", timeout))
}
if result == "" {
result = err.Error()
}
return ErrorResult(result)
}
if result == "" {
result = "(command completed with no output)"
}
return SilentResult(result)
}
// renderCommand replaces {{.key}} placeholders with shell-escaped arg values.
// Uses simple string replacement (NOT Go text/template) for security.
func renderCommand(tmpl string, args map[string]any) string {
result := tmpl
for key, val := range args {
placeholder := "{{." + key + "}}"
escaped := shellEscape(fmt.Sprint(val))
result = strings.ReplaceAll(result, placeholder, escaped)
}
return result
}
// shellEscape wraps a value in single quotes, escaping embedded single quotes.
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}