277 lines
8.7 KiB
Go
277 lines
8.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// maxCommandTimeout es el máximo que el modelo puede pedir para un comando.
|
|
const maxCommandTimeout = 2 * time.Hour
|
|
|
|
// CommandSpec describe un proceso a ejecutar.
|
|
type CommandSpec struct {
|
|
Name string
|
|
Args []string
|
|
Dir string // directorio de trabajo; no cambia el del agente
|
|
Timeout time.Duration // obligatorio (> 0)
|
|
TmpDir string // dónde guardar salidas extensas
|
|
Env []string // variables adicionales (p.ej. secretos para shell)
|
|
}
|
|
|
|
// CommandResult es el resultado de RunCommand.
|
|
type CommandResult struct {
|
|
ExitCode int // -1 si no llegó a ejecutarse o fue matado
|
|
Stdout string
|
|
Stderr string
|
|
Duration time.Duration
|
|
TimedOut bool
|
|
Err error // error de arranque o de espera (no incluye exit != 0)
|
|
Spilled []string
|
|
}
|
|
|
|
// commandEnv es el entorno de los procesos hijos: se hereda el del agente
|
|
// y se fuerzan valores no interactivos para que ningún comando se quede
|
|
// esperando entrada o abra un paginador.
|
|
func commandEnv() []string {
|
|
env := os.Environ()
|
|
return append(env,
|
|
"DEBIAN_FRONTEND=noninteractive",
|
|
"APT_LISTCHANGES_FRONTEND=none",
|
|
"PAGER=cat", "SYSTEMD_PAGER=cat", "GIT_PAGER=cat", "MANPAGER=cat",
|
|
"SYSTEMD_COLORS=0", "NO_COLOR=1", "TERM=dumb",
|
|
)
|
|
}
|
|
|
|
// RunCommand ejecuta un programa con argumentos, sin shell intermedio.
|
|
//
|
|
// Entradas: spec.Name/Args (sin interpretación de shell: no hay problemas
|
|
// de quoting), spec.Dir y spec.Timeout.
|
|
// Salidas: stdout y stderr por separado, código de salida y si hubo timeout.
|
|
// Efectos: el proceso corre en su propio grupo; al vencer el timeout o
|
|
// cancelarse ctx se mata el grupo completo. stdin es /dev/null.
|
|
func RunCommand(ctx context.Context, spec CommandSpec) CommandResult {
|
|
ctx, cancel := context.WithTimeout(ctx, spec.Timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
|
cmd.Dir = spec.Dir
|
|
cmd.Env = append(commandEnv(), spec.Env...)
|
|
cmd.Stdin = nil
|
|
setProcessGroup(cmd)
|
|
// Si un nieto mantiene abiertas las tuberías, no esperar más de esto.
|
|
cmd.WaitDelay = 5 * time.Second
|
|
|
|
stdout := newSpillWriter(spec.TmpDir, "stdout")
|
|
stderr := newSpillWriter(spec.TmpDir, "stderr")
|
|
defer stdout.Close()
|
|
defer stderr.Close()
|
|
cmd.Stdout, cmd.Stderr = stdout, stderr
|
|
|
|
start := time.Now()
|
|
err := cmd.Run()
|
|
res := CommandResult{Duration: time.Since(start), ExitCode: -1}
|
|
res.Stdout, res.Stderr = stdout.Text(), stderr.Text()
|
|
for _, p := range []string{stdout.SpillPath(), stderr.SpillPath()} {
|
|
if p != "" {
|
|
res.Spilled = append(res.Spilled, p)
|
|
}
|
|
}
|
|
|
|
var exitErr *exec.ExitError
|
|
switch {
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
res.TimedOut = true
|
|
res.Err = fmt.Errorf("timed out after %s", spec.Timeout)
|
|
case err == nil:
|
|
res.ExitCode = 0
|
|
case errors.As(err, &exitErr):
|
|
res.ExitCode = exitErr.ExitCode()
|
|
if ctx.Err() != nil {
|
|
res.Err = ctx.Err()
|
|
}
|
|
default:
|
|
res.Err = err
|
|
}
|
|
return res
|
|
}
|
|
|
|
// format produce el texto para el modelo.
|
|
func (r CommandResult) format(header string) string {
|
|
var b strings.Builder
|
|
b.WriteString(header)
|
|
fmt.Fprintf(&b, "exit_code: %d\nduration: %s\n", r.ExitCode, r.Duration.Round(time.Millisecond))
|
|
if r.TimedOut {
|
|
b.WriteString("timed_out: true (process group killed)\n")
|
|
}
|
|
if r.Err != nil && !r.TimedOut {
|
|
fmt.Fprintf(&b, "error: %v\n", r.Err)
|
|
}
|
|
fmt.Fprintf(&b, "--- stdout ---\n%s", r.Stdout)
|
|
if r.Stdout != "" && !strings.HasSuffix(r.Stdout, "\n") {
|
|
b.WriteByte('\n')
|
|
}
|
|
fmt.Fprintf(&b, "--- stderr ---\n%s", r.Stderr)
|
|
return b.String()
|
|
}
|
|
|
|
// resolveTimeout aplica el valor por defecto de la política y el máximo.
|
|
func resolveTimeout(env *Env, seconds int) time.Duration {
|
|
if seconds <= 0 {
|
|
return env.Policy.DefaultCommandTimeout
|
|
}
|
|
return min(time.Duration(seconds)*time.Second, maxCommandTimeout)
|
|
}
|
|
|
|
func quoteArgv(argv []string) string {
|
|
parts := make([]string, len(argv))
|
|
for i, a := range argv {
|
|
if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`;&|<>(){}*?[]#~") {
|
|
parts[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
|
|
} else {
|
|
parts[i] = a
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ---- exec ----
|
|
|
|
type execTool struct{}
|
|
|
|
type execArgs struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
Cwd string `json:"cwd"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
}
|
|
|
|
func (execTool) Name() string { return "exec" }
|
|
func (execTool) Description() string {
|
|
return "Run a program directly (no shell): command + args. Preferred over shell when no pipes/redirections are needed. Output: exit_code, stdout, stderr."
|
|
}
|
|
func (execTool) Schema() map[string]any {
|
|
return obj([]string{"command"}, map[string]any{
|
|
"command": str("program name or path, e.g. systemctl"),
|
|
"args": strList("arguments, one per element, e.g. [\"status\",\"nginx\"]"),
|
|
"cwd": str("working directory (default PROJECT_ROOT)"),
|
|
"timeout_seconds": num("timeout in seconds (default from policy, max 7200)"),
|
|
})
|
|
}
|
|
|
|
func (execTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a execArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("exec", err)
|
|
}
|
|
if strings.TrimSpace(a.Command) == "" {
|
|
return fail("exec", errors.New("command is required"))
|
|
}
|
|
argv := append([]string{a.Command}, a.Args...)
|
|
cwd := env.Guard.Resolve(a.Cwd, "")
|
|
display := quoteArgv(argv)
|
|
|
|
cls, err := env.Guard.CheckArgv(argv, cwd)
|
|
if err != nil {
|
|
return fail(display, err)
|
|
}
|
|
if env.DryRun && !cls.ReadOnly() {
|
|
return dryRun(fmt.Sprintf("run in %s: %s (risks: %v)", cwd, display, cls.AllRisks()))
|
|
}
|
|
if st, err := os.Stat(cwd); err != nil || !st.IsDir() {
|
|
return fail(display, fmt.Errorf("working directory %s does not exist", cwd))
|
|
}
|
|
|
|
args := make([]string, len(a.Args))
|
|
for i, arg := range a.Args {
|
|
v, err := env.Secrets.Expand(arg)
|
|
if err != nil {
|
|
return fail(display, err)
|
|
}
|
|
args[i] = v
|
|
}
|
|
spec := CommandSpec{Name: a.Command, Args: args, Dir: cwd, Timeout: resolveTimeout(env, a.TimeoutSeconds), TmpDir: tmpDir(env.Root)}
|
|
return runWithRetry(ctx, env, spec, display, cwd, cls.AllRisks())
|
|
}
|
|
|
|
func commandResult(display, cwd string, r CommandResult) Result {
|
|
header := fmt.Sprintf("command: %s\ncwd: %s\n", display, cwd)
|
|
summary := fmt.Sprintf("%s → exit=%d", display, r.ExitCode)
|
|
if r.TimedOut {
|
|
summary = display + " → timed out"
|
|
} else if r.Err != nil && r.ExitCode < 0 {
|
|
summary = fmt.Sprintf("%s → %v", display, r.Err)
|
|
}
|
|
return Result{
|
|
Output: r.format(header),
|
|
OK: r.ExitCode == 0 && r.Err == nil,
|
|
Summary: summary,
|
|
ExitCode: r.ExitCode,
|
|
Audit: []string{"command", display},
|
|
}
|
|
}
|
|
|
|
// ---- shell ----
|
|
|
|
type shellTool struct{}
|
|
|
|
type shellArgs struct {
|
|
Script string `json:"script"`
|
|
Cwd string `json:"cwd"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
}
|
|
|
|
func (shellTool) Name() string { return "shell" }
|
|
func (shellTool) Description() string {
|
|
return "Run a shell expression with bash -lc (pipes, redirections, &&, loops). Use exec instead for single programs. Every command in the script is checked against the policy."
|
|
}
|
|
func (shellTool) Schema() map[string]any {
|
|
return obj([]string{"script"}, map[string]any{
|
|
"script": str("shell script, e.g. \"journalctl -u nginx -n 100 | grep -i error\""),
|
|
"cwd": str("working directory (default PROJECT_ROOT)"),
|
|
"timeout_seconds": num("timeout in seconds (default from policy, max 7200)"),
|
|
})
|
|
}
|
|
|
|
func (shellTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result {
|
|
var a shellArgs
|
|
if err := decode(raw, &a); err != nil {
|
|
return fail("shell", err)
|
|
}
|
|
if strings.TrimSpace(a.Script) == "" {
|
|
return fail("shell", errors.New("script is required"))
|
|
}
|
|
cwd := env.Guard.Resolve(a.Cwd, "")
|
|
display := "sh: " + clip(strings.ReplaceAll(a.Script, "\n", "; "), 300)
|
|
|
|
cls, err := env.Guard.CheckScript(a.Script, cwd)
|
|
if err != nil {
|
|
return fail(display, err)
|
|
}
|
|
if env.DryRun && !cls.ReadOnly() {
|
|
return dryRun(fmt.Sprintf("run shell script in %s: %s (risks: %v)", cwd, a.Script, cls.AllRisks()))
|
|
}
|
|
if st, err := os.Stat(cwd); err != nil || !st.IsDir() {
|
|
return fail(display, fmt.Errorf("working directory %s does not exist", cwd))
|
|
}
|
|
|
|
shell, flag := "bash", "-lc"
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
shell, flag = "sh", "-c"
|
|
}
|
|
script, secretEnv, err := env.Secrets.ShellEnv(a.Script)
|
|
if err != nil {
|
|
return fail(display, err)
|
|
}
|
|
spec := CommandSpec{Name: shell, Args: []string{flag, script}, Dir: cwd, Timeout: resolveTimeout(env, a.TimeoutSeconds), TmpDir: tmpDir(env.Root), Env: secretEnv}
|
|
res := runWithRetry(ctx, env, spec, display, cwd, cls.AllRisks())
|
|
res.Output = strings.Replace(res.Output, "command: "+display, "script:\n"+a.Script, 1)
|
|
res.Audit = []string{"command", a.Script}
|
|
return res
|
|
}
|