chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user