post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+276
View File
@@ -0,0 +1,276 @@
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
}