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 }