mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 12:10:58 +00:00
843b550651
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)
134 lines
3.5 KiB
Go
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, ¶ms)
|
|
}
|
|
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, "'", "'\\''") + "'"
|
|
}
|