327 lines
8.7 KiB
Go
327 lines
8.7 KiB
Go
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
|
|
}
|