chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-30 17:28:38 +02:00
commit 4b0fb61f02
28 changed files with 3271 additions and 0 deletions
+316
View File
@@ -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
}