Files
device_agent/capability.go
T
2026-05-30 17:28:38 +02:00

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
}