package main import ( "encoding/json" "fmt" "net/http" "os/exec" "strings" "time" ) // CapabilityRequest envelope minimo POC (sin firma todavia — issue 0134 v0.2). type CapabilityRequest struct { RequestID string `json:"request_id"` Capability string `json:"capability"` Args json.RawMessage `json:"args"` Nonce string `json:"nonce"` Timestamp int64 `json:"ts"` } // parseArgsArray intenta decode args como []string. Si args es un objeto // con campo "argv" (formato MCP), extrae argv. Si es array plano, lo devuelve. func parseArgsArray(raw json.RawMessage) ([]string, error) { if len(raw) == 0 { return nil, nil } // Intento 1: array var arr []string if err := json.Unmarshal(raw, &arr); err == nil { return arr, nil } // Intento 2: object con argv var obj map[string]any if err := json.Unmarshal(raw, &obj); err != nil { return nil, fmt.Errorf("args must be array or object with argv: %w", err) } if v, ok := obj["argv"]; ok { switch t := v.(type) { case []any: out := make([]string, len(t)) for i, x := range t { out[i] = fmt.Sprintf("%v", x) } return out, nil case []string: return t, nil } } return nil, fmt.Errorf("args object missing argv array") } // parseArgsMap decodifica args como map[string]any para capabilities que // reciben objeto. Acepta tambien array (lo wrap como {argv:[...]}). func parseArgsMap(raw json.RawMessage) (map[string]any, error) { if len(raw) == 0 { return nil, nil } var obj map[string]any if err := json.Unmarshal(raw, &obj); err == nil { return obj, nil } // Fallback: array → {argv: [...]} var arr []any if err := json.Unmarshal(raw, &arr); err != nil { return nil, fmt.Errorf("args must be object or array: %w", err) } return map[string]any{"argv": arr}, nil } type CapabilityResponse struct { RequestID string `json:"request_id"` OK bool `json:"ok"` Result any `json:"result,omitempty"` Error string `json:"error,omitempty"` DurationMs int64 `json:"duration_ms"` AuditHash string `json:"audit_hash,omitempty"` } func capabilityHandler(mf *Manifest, audit *Audit) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") return } var req CapabilityRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.RequestID == "" || req.Capability == "" { writeError(w, http.StatusBadRequest, "bad_request", "request_id and capability required") return } cap := mf.CapabilityByName(req.Capability) if cap == nil { writeError(w, http.StatusForbidden, "capability_denied", fmt.Sprintf("capability %q not in manifest", req.Capability)) return } start := time.Now() var result any var execErr error exitCode := 0 var shellEvalExtra *ShellEvalRecord switch req.Capability { case "shell.exec": argv, perr := parseArgsArray(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runShellExec(cap, argv) case "shell.eval": rawMap, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } var r map[string]any r, exitCode, shellEvalExtra, execErr = runShellEval(cap, rawMap) result = r case "fs.read": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runFsRead(cap, m) case "fs.write": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runFsWrite(cap, m) case "fs.list": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runFsList(cap, m) case "fs.stat": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runFsStat(cap, m) case "git.clone": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runGitClone(cap, m) case "git.commit": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runGitCommit(cap, m) case "git.push": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runGitPush(cap, m) case "pkg.search": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runPkgSearch(cap, m) case "proc.list": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runProcList(cap, m) case "docker.container.list": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runDockerList(cap, m) case "docker.container.exec": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runDockerExec(cap, m) case "docker.container.logs": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } result, exitCode, execErr = runDockerLogs(cap, m) case "browser.list_tabs", "browser.navigate", "browser.click_text", "browser.evaluate", "browser.screenshot", "browser.type_text", "browser.get_html", "browser.launch_chrome": m, perr := parseArgsMap(req.Args) if perr != nil { execErr = perr break } op := strings.TrimPrefix(req.Capability, "browser.") result, exitCode, execErr = runBrowserOp(cap, op, m) default: execErr = fmt.Errorf("capability %q not implemented yet in POC", req.Capability) } dur := time.Since(start).Milliseconds() var hash string var herr error if req.Capability == "shell.eval" && shellEvalExtra != nil { hash, herr = audit.AppendVerbose(req.RequestID, req.Capability, req.Args, exitCode, *shellEvalExtra) } else { hash, herr = audit.Append(req.RequestID, req.Capability, req.Args, exitCode) } if herr != nil { // audit es critico — si falla, devolvemos error pero no perdemos el output. writeJSON(w, http.StatusInternalServerError, CapabilityResponse{ RequestID: req.RequestID, OK: false, Error: "audit_failed: " + herr.Error(), DurationMs: dur, }) return } resp := CapabilityResponse{ RequestID: req.RequestID, DurationMs: dur, AuditHash: hash, } if execErr != nil { resp.OK = false resp.Error = execErr.Error() writeJSON(w, http.StatusInternalServerError, resp) return } resp.OK = true resp.Result = result writeJSON(w, http.StatusOK, resp) } } // parseShellEvalArgs decodifica los args de la envelope shell.eval. // Convencion: req.Args puede ser: // - len(args) == 1 y args[0] es un JSON object {"cmd":..., "shell":..., "cwd":...} // - len(args) >= 1 y args[0] = cmd, args[1] (opcional) = cwd, args[2] (opcional) = shell // // Devuelve un map[string]any compatible con runShellEval. func parseShellEvalArgs(args []string) (map[string]any, error) { if len(args) == 0 { return nil, fmt.Errorf("shell.eval requires at least 1 arg") } // Intento 1: JSON object string. trim := strings.TrimSpace(args[0]) if strings.HasPrefix(trim, "{") && strings.HasSuffix(trim, "}") { var m map[string]any if err := json.Unmarshal([]byte(trim), &m); err == nil { return m, nil } // caemos al fallback } out := map[string]any{ "cmd": args[0], } if len(args) >= 2 && args[1] != "" { out["cwd"] = args[1] } if len(args) >= 3 && args[2] != "" { out["shell"] = args[2] } return out, nil } // runShellExec implementa shell.exec con whitelist del manifest local. // POC INLINE: el flow 0009 v0.2 lo migra a shell_exec_whitelist_go_infra del registry. func runShellExec(cap *Capability, args []string) (any, int, error) { if len(args) == 0 { return nil, -1, fmt.Errorf("no argv provided") } if len(cap.BinariesAllowed) == 0 { return nil, -1, fmt.Errorf("no binaries whitelisted for shell.exec") } bin := args[0] allowed := false for _, b := range cap.BinariesAllowed { if b == bin || strings.HasSuffix(bin, "/"+b) { allowed = true break } } if !allowed { return nil, -1, fmt.Errorf("binary %q not in whitelist %v", bin, cap.BinariesAllowed) } cmd := exec.Command(args[0], args[1:]...) // #nosec G204 — whitelist enforced above out, err := cmd.CombinedOutput() exitCode := 0 if err != nil { if ee, ok := err.(*exec.ExitError); ok { exitCode = ee.ExitCode() } else { return nil, -1, err } } return map[string]any{ "stdout": string(out), "exit_code": exitCode, }, exitCode, nil }