317 lines
8.1 KiB
Go
317 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ShellEvalArgs es el payload args para shell.eval (decodificado desde un map).
|
|
type ShellEvalArgs struct {
|
|
Cmd string `json:"cmd"`
|
|
Shell string `json:"shell,omitempty"` // bash|powershell|auto (default auto)
|
|
CWD string `json:"cwd,omitempty"`
|
|
}
|
|
|
|
// hardBlocklist son patrones bloqueados SIEMPRE (no configurable).
|
|
// Match case-insensitive. Cualquier match = rechazo, no se puede aprobar.
|
|
var hardBlocklist = []string{
|
|
`rm\s+-rf\s+/`,
|
|
`rm\s+-rf\s+\$HOME`,
|
|
`rm\s+-rf\s+~/?\s*$`,
|
|
`dd\s+if=`,
|
|
`mkfs\.`,
|
|
`chmod\s+-R\s+777\s+/`,
|
|
`curl[^|]*\|\s*sh`,
|
|
`wget[^|]*\|\s*sh`,
|
|
`wget[^|]*\|\s*bash`,
|
|
`>\s*/dev/sd[a-z]`,
|
|
`>\s*/dev/nvme`,
|
|
`:(){\s*:\|:&\s*};:`,
|
|
`:\(\)\s*{\s*:\|:&\s*};:`,
|
|
`shutdown\s+-h`,
|
|
`reboot\b`,
|
|
`halt\b`,
|
|
`init\s+0`,
|
|
`init\s+6`,
|
|
`format\s+[c-z]:`,
|
|
}
|
|
|
|
// defaultAutoApprove se usa cuando Capability.AutoApprove esta vacio.
|
|
var defaultAutoApprove = []string{
|
|
`^git\s`,
|
|
`^ls\s`,
|
|
`^cat\s`,
|
|
`^grep\s`,
|
|
`^find\s`,
|
|
`^docker\s+ps`,
|
|
`^docker\s+logs`,
|
|
`^ps\b`,
|
|
`^df\s`,
|
|
`^du\s`,
|
|
`^echo\s`,
|
|
`^pwd$`,
|
|
`^hostname$`,
|
|
`^whoami$`,
|
|
`^date$`,
|
|
`^uname`,
|
|
}
|
|
|
|
// approvalQueuePath path del jsonl que persiste solicitudes pendientes
|
|
// de aprobacion (mecanismo placeholder hasta 0144f).
|
|
var approvalQueuePath = filepath.Join("local_files", "approval_queue.jsonl")
|
|
|
|
// runShellEval implementa la capability shell.eval.
|
|
//
|
|
// Devuelve (result, exitCode, err). Si err != nil, result deberia ser nil.
|
|
// extra contiene los campos cleartext (cmd/cwd/shell/stdout/stderr) que
|
|
// el caller pasara a audit.AppendVerbose. Si no se ejecuto (blocklist /
|
|
// approval_required), extra puede ser nil.
|
|
func runShellEval(cap *Capability, raw map[string]any) (result map[string]any, exitCode int, extra *ShellEvalRecord, err error) {
|
|
// 1. Parse args desde raw via json roundtrip.
|
|
rawJSON, jerr := json.Marshal(raw)
|
|
if jerr != nil {
|
|
return nil, -1, nil, fmt.Errorf("marshal raw args: %w", jerr)
|
|
}
|
|
var args ShellEvalArgs
|
|
if jerr := json.Unmarshal(rawJSON, &args); jerr != nil {
|
|
return nil, -1, nil, fmt.Errorf("unmarshal args: %w", jerr)
|
|
}
|
|
args.Cmd = strings.TrimSpace(args.Cmd)
|
|
if args.Cmd == "" {
|
|
return nil, -1, nil, fmt.Errorf("cmd is required")
|
|
}
|
|
|
|
// 2. Hardcoded blocklist + capability extension.
|
|
blocklist := append([]string{}, hardBlocklist...)
|
|
blocklist = append(blocklist, cap.Blocklist...)
|
|
for _, pat := range blocklist {
|
|
re, rerr := regexp.Compile(`(?i)` + pat)
|
|
if rerr != nil {
|
|
// pattern operator invalido -> skip pero no bloqueamos.
|
|
continue
|
|
}
|
|
if re.MatchString(args.Cmd) {
|
|
return nil, -1, nil, fmt.Errorf("blocked: matches hardcoded safety blocklist (%s)", pat)
|
|
}
|
|
}
|
|
|
|
// 3. OS detect + shell selection.
|
|
chosenShell, sErr := resolveShell(cap, args.Shell)
|
|
if sErr != nil {
|
|
return nil, -1, nil, sErr
|
|
}
|
|
|
|
// 4. Auto-approval check.
|
|
autoApprove := cap.AutoApprove
|
|
if len(autoApprove) == 0 {
|
|
autoApprove = defaultAutoApprove
|
|
}
|
|
approved := false
|
|
for _, pat := range autoApprove {
|
|
re, rerr := regexp.Compile(pat)
|
|
if rerr != nil {
|
|
continue
|
|
}
|
|
if re.MatchString(args.Cmd) {
|
|
approved = true
|
|
break
|
|
}
|
|
}
|
|
|
|
approvalStatus := "none-required"
|
|
if approved {
|
|
approvalStatus = "auto-approved"
|
|
} else if cap.RequiresApproval {
|
|
// 5. requires_approval true + not auto -> queue + error
|
|
if err := enqueueApproval(args, cap); err != nil {
|
|
return nil, -1, nil, fmt.Errorf("enqueue approval failed: %w", err)
|
|
}
|
|
return nil, -1, nil, fmt.Errorf("approval_required: cmd queued at %s", approvalQueuePath)
|
|
}
|
|
|
|
// 6. Exec.
|
|
timeoutS := cap.TimeoutSeconds
|
|
if timeoutS <= 0 {
|
|
timeoutS = 60
|
|
}
|
|
maxOut := cap.MaxOutputBytes
|
|
if maxOut <= 0 {
|
|
maxOut = 1024 * 1024 // 1MB
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutS)*time.Second)
|
|
defer cancel()
|
|
|
|
var c *exec.Cmd
|
|
switch chosenShell {
|
|
case "bash":
|
|
c = exec.CommandContext(ctx, "bash", "-c", args.Cmd)
|
|
case "powershell":
|
|
bin := "powershell.exe"
|
|
if _, lerr := exec.LookPath(bin); lerr != nil {
|
|
if _, lerr2 := exec.LookPath("pwsh"); lerr2 == nil {
|
|
bin = "pwsh"
|
|
}
|
|
}
|
|
c = exec.CommandContext(ctx, bin, "-NoProfile", "-NonInteractive", "-Command", args.Cmd)
|
|
default:
|
|
return nil, -1, nil, fmt.Errorf("unknown shell %q", chosenShell)
|
|
}
|
|
|
|
if args.CWD != "" {
|
|
if !filepath.IsAbs(args.CWD) {
|
|
return nil, -1, nil, fmt.Errorf("cwd must be absolute path")
|
|
}
|
|
st, serr := os.Stat(args.CWD)
|
|
if serr != nil || !st.IsDir() {
|
|
return nil, -1, nil, fmt.Errorf("cwd does not exist or not a dir: %s", args.CWD)
|
|
}
|
|
c.Dir = args.CWD
|
|
}
|
|
|
|
var stdoutBuf, stderrBuf strings.Builder
|
|
c.Stdout = &capWriter{buf: &stdoutBuf, max: maxOut}
|
|
c.Stderr = &capWriter{buf: &stderrBuf, max: maxOut}
|
|
|
|
start := time.Now()
|
|
runErr := c.Run()
|
|
dur := time.Since(start).Milliseconds()
|
|
|
|
exitCode = 0
|
|
if runErr != nil {
|
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
|
exitCode = ee.ExitCode()
|
|
} else if ctx.Err() == context.DeadlineExceeded {
|
|
exitCode = 124 // convencion timeout
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
}
|
|
|
|
stdoutS := stdoutBuf.String()
|
|
stderrS := stderrBuf.String()
|
|
truncated := false
|
|
if sw, ok := c.Stdout.(*capWriter); ok && sw.truncated {
|
|
truncated = true
|
|
}
|
|
if sw, ok := c.Stderr.(*capWriter); ok && sw.truncated {
|
|
truncated = true
|
|
}
|
|
|
|
extra = &ShellEvalRecord{
|
|
Cmd: args.Cmd,
|
|
CWD: args.CWD,
|
|
Shell: chosenShell,
|
|
Stdout: stdoutS,
|
|
Stderr: stderrS,
|
|
}
|
|
|
|
result = map[string]any{
|
|
"stdout": stdoutS,
|
|
"stderr": stderrS,
|
|
"exit_code": exitCode,
|
|
"shell": chosenShell,
|
|
"approval_status": approvalStatus,
|
|
"truncated": truncated,
|
|
"duration_ms": dur,
|
|
"cmd_executed": args.Cmd,
|
|
}
|
|
return result, exitCode, extra, nil
|
|
}
|
|
|
|
// resolveShell decide bash o powershell segun GOOS + override (cap.ShellMode/args.Shell).
|
|
// Reglas:
|
|
// - args.Shell (call-level) > cap.ShellMode (manifest) > GOOS-default.
|
|
// - "auto" = GOOS-default.
|
|
// - powershell en linux solo si pwsh esta en PATH.
|
|
// - bash en windows: rechazado.
|
|
func resolveShell(cap *Capability, callShell string) (string, error) {
|
|
mode := strings.ToLower(strings.TrimSpace(callShell))
|
|
if mode == "" {
|
|
mode = strings.ToLower(strings.TrimSpace(cap.ShellMode))
|
|
}
|
|
if mode == "" || mode == "auto" {
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
return "powershell", nil
|
|
default:
|
|
return "bash", nil
|
|
}
|
|
}
|
|
switch mode {
|
|
case "bash":
|
|
if runtime.GOOS == "windows" {
|
|
return "", fmt.Errorf("bash on windows not supported by this agent")
|
|
}
|
|
return "bash", nil
|
|
case "powershell":
|
|
if runtime.GOOS != "windows" {
|
|
if _, err := exec.LookPath("pwsh"); err != nil {
|
|
return "", fmt.Errorf("powershell requested but pwsh not in PATH on %s", runtime.GOOS)
|
|
}
|
|
}
|
|
return "powershell", nil
|
|
default:
|
|
return "", fmt.Errorf("invalid shell mode %q (want bash|powershell|auto)", mode)
|
|
}
|
|
}
|
|
|
|
// enqueueApproval anade una entrada al jsonl. POC: el agent LLM observa el fichero.
|
|
// En 0144f pasa a Matrix reactions.
|
|
func enqueueApproval(args ShellEvalArgs, cap *Capability) error {
|
|
if err := os.MkdirAll(filepath.Dir(approvalQueuePath), 0700); err != nil {
|
|
return err
|
|
}
|
|
entry := map[string]any{
|
|
"ts": time.Now().Unix(),
|
|
"request_id": fmt.Sprintf("approval-%d", time.Now().UnixNano()),
|
|
"cmd": args.Cmd,
|
|
"cwd": args.CWD,
|
|
"capability": cap.Name,
|
|
"status": "pending",
|
|
}
|
|
b, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(approvalQueuePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(append(b, '\n')); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// capWriter es un io.Writer que trunca a max bytes y flag truncated.
|
|
type capWriter struct {
|
|
buf *strings.Builder
|
|
max int
|
|
truncated bool
|
|
}
|
|
|
|
func (w *capWriter) Write(p []byte) (int, error) {
|
|
remaining := w.max - w.buf.Len()
|
|
if remaining <= 0 {
|
|
w.truncated = true
|
|
return len(p), nil // descartamos pero pretendemos exito para no romper proceso
|
|
}
|
|
if len(p) > remaining {
|
|
w.buf.Write(p[:remaining])
|
|
w.truncated = true
|
|
return len(p), nil
|
|
}
|
|
w.buf.Write(p)
|
|
return len(p), nil
|
|
}
|