commit 4b0fb61f0212529c2eb84510633622e05c6b5a39 Author: fn-registry agent Date: Sat May 30 17:28:38 2026 +0200 chore: sync from fn-registry agent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..386d283 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +registry.db diff --git a/app.md b/app.md new file mode 100644 index 0000000..596f5aa --- /dev/null +++ b/app.md @@ -0,0 +1,166 @@ +--- +name: device_agent +lang: go +domain: tools +description: "Agente HTTP por dispositivo del mesh WireGuard. Recibe capability requests del Matrix bot agents_and_robots (via mesh 10.42.0.0/24), valida contra manifest YAML, ejecuta con sandbox (shell whitelist o docker exec whitelist), devuelve resultado con audit hash-chained." +icon: + phosphor: "robot" + accent: "#06b6d4" +tags: [agents, device-agent, mesh, wireguard, matrix, http, service, security] +version: 0.2.0 +uses_functions: + - shell_exec_whitelist_go_infra + - docker_container_list_go_infra + - docker_container_exec_go_infra + - docker_container_logs_go_infra + - http_logger_middleware_go_infra + - logger_new_go_infra +uses_types: [] +framework: "stdlib-http" +entry_point: "main.go" +dir_path: "projects/element_agents/apps/device_agent" +repo_url: "https://gitea.organic-machine.com/dataforge/device_agent" +e2e_checks: + - id: build + cmd: "cd /home/lucas/fn_registry/projects/element_agents/apps/device_agent && CGO_ENABLED=1 go build -o device_agent ." + timeout_s: 60 + - id: self_test + cmd: "/home/lucas/fn_registry/projects/element_agents/apps/device_agent/device_agent --self-test" + timeout_s: 10 + - id: unit_tests + cmd: "cd /home/lucas/fn_registry/projects/element_agents/apps/device_agent && CGO_ENABLED=1 go test -count=1 ./..." + timeout_s: 60 +service: + port: 7474 + health_endpoint: /health + health_timeout_s: 3 + systemd_unit: device_agent.service + systemd_scope: user + restart_policy: always + runtime: systemd-user + pc_targets: + - home-wsl + - aurgi-pc + is_local_only: false +--- + +# device_agent + +Agente HTTP que corre en cada dispositivo del mesh WireGuard mesh (10.42.0.0/24). Escucha en la IP del mesh asignada al peer (`10.42.0.10` en home-wsl, etc.) puerto 7474. + +## Endpoints + +| Metodo | Path | Descripcion | +|---|---|---| +| GET | `/health` | Liveness check, devuelve `{"ok":true,"device_id":"...","version":"..."}` | +| GET | `/capabilities` | Lista capabilities declaradas en el manifest local | +| POST | `/capability` | Despacha capability request. JSON envelope (ver flow 0009 spec issue 0134) | + +## Flujo + +``` +agents_and_robots (VPS, 10.42.0.1) + ↓ POST http://10.42.0.10:7474/capability +device_agent (este binario) + ↓ validate manifest + nonce + (later) signature + ↓ route capability → shell.exec | docker.* | fs.read | ... + ↓ append audit hash-chain + ↓ return JSON {ok, result, audit_hash} +agents_and_robots + ↓ Matrix message back to room +Element user ve output +``` + +## Manifest + +`~/.config/device_agent/manifest.yaml` declara capabilities permitidas. POC inicial sin firma (issue 0134 introduce ed25519 sign). Formato: + +```yaml +device_id: home-wsl +operator: egutierrez +capabilities: + - name: shell.exec + binaries_allowed: [ls, cat, ps, df, git, echo] + requires_approval: false + - name: shell.exec.admin + binaries_allowed: [systemctl, apt-get] + requires_approval: true + - name: shell.eval + shell_mode: auto # bash en linux/darwin, powershell.exe en windows + blocklist: [] # extension operador; hardcoded kill-list aplica siempre + auto_approve: # regex pre-aprobados (override defaults si presente) + - "^git\\s" + - "^docker ps" + max_output_bytes: 1048576 # 1MB + timeout_seconds: 60 + requires_approval: false # true => cmd no-auto se cola en local_files/approval_queue.jsonl + - name: docker.container.list + requires_approval: false + - name: docker.container.logs + requires_approval: false + - name: docker.container.exec + binaries_allowed: [ls, ps, cat] + requires_approval: true +``` + +### Capabilities soportadas + +| Capability | Estado | Que hace | +|---|---|---| +| `shell.exec` | v0.1.0 | Ejecuta argv estructurado, whitelist binaries | +| `shell.eval` | v0.2.0 | Evalua cmd shell-libre (`bash -c ` o `powershell.exe -Command `). Hardcoded blocklist + auto_approve regex + approval queue + audit verbose con cmd cleartext | +| `docker.container.list` | stub | Lista contenedores via socket docker | +| `docker.container.logs` | stub | Logs de un contenedor | +| `docker.container.exec` | stub | exec en contenedor (whitelist) | + +### shell.eval — detalle + +`shell.eval` permite al agent LLM mandar comandos shell libres ("borra logs antiguos") en lugar de solo argv estructurado. Defensas: + +1. **Hardcoded blocklist** no configurable: `rm -rf /`, `dd if=`, `mkfs.*`, `curl|sh`, `shutdown`, `reboot`, etc. Match case-insensitive. Cualquier match = rechazo, no aprobable. +2. **Operator blocklist** (`capability.blocklist[]`) extiende el hardcoded. +3. **OS detect**: `bash -c` en Linux/Mac, `powershell.exe -NoProfile -NonInteractive -Command` en Windows. Override via `shell_mode` o per-call `shell` arg. +4. **auto_approve regex**: lista (override-able) de patrones pre-aprobados (`^git\s`, `^docker ps`, etc.). Match = ejecuta sin friccion. +5. **Approval queue**: si `requires_approval: true` y el cmd NO matchea `auto_approve`, se anade entry a `local_files/approval_queue.jsonl` `{ts, request_id, cmd, cwd, capability, status: pending}` y devuelve error `approval_required`. v0.2.0 es placeholder; 0144f pasa a Matrix reactions. +6. **Output cap**: `max_output_bytes` (default 1MB), `timeout_seconds` (default 60). Flag `truncated:true` cuando se aplica. +7. **Audit verbose**: nueva tabla `audit_shell_eval(audit_id, cmd, cwd, shell, stdout_b64, stderr_b64)` con cmd en CLARO (no hash) para forense. stdout/stderr > 4KB se comprimen gzip+base64 (prefijo `gz:`); cortos van con prefijo `plain:`. + +Args en la HTTP envelope (`POST /capability`): + +```json +{ + "request_id": "...", + "capability": "shell.eval", + "args": ["{\"cmd\":\"git status\",\"cwd\":\"/repo\"}"] +} +``` + +O en formato posicional `args: ["git status", "/repo", "bash"]`. + +## Audit + +`local_files/audit.db` con tabla `audit_log` hash-chained. Cada request: `{ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash}`. + +## Build + +```bash +cd projects/element_agents/apps/device_agent +CGO_ENABLED=1 go build -o device_agent . +./device_agent --listen 10.42.0.10:7474 --manifest ~/.config/device_agent/manifest.yaml +``` + +Cross-compile a Windows (para aurgi-pc): + +```bash +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o device_agent.exe . +``` + +## Estado + +- v0.1.0: POC sin firma de manifest. Solo shell.exec + docker.*. WIP. +- v0.2.0 (issue 0140): firma ed25519, replay protection, approval flow. + +## Capability growth log + +- v0.1.0 (2026-05-24) — scaffold inicial POC para validar DoD gate Element→PC del flow 0009. +- v0.2.0 (2026-05-24) — anade capability `shell.eval` (bash/powershell con hardcoded blocklist + regex auto_approve + approval queue + audit verbose con cmd cleartext). Nueva tabla `audit_shell_eval`. Manifest extendido con `blocklist`, `auto_approve`, `shell_mode`, `max_output_bytes`, `timeout_seconds`. diff --git a/audit.go b/audit.go new file mode 100644 index 0000000..b4cce7e --- /dev/null +++ b/audit.go @@ -0,0 +1,180 @@ +package main + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +type Audit struct { + db *sql.DB +} + +const auditSchema = ` +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + request_id TEXT NOT NULL, + capability TEXT NOT NULL, + args_hash TEXT NOT NULL, + exit_code INTEGER NOT NULL, + prev_hash TEXT NOT NULL, + this_hash TEXT NOT NULL UNIQUE +); +CREATE INDEX IF NOT EXISTS audit_log_ts ON audit_log(ts); +CREATE INDEX IF NOT EXISTS audit_log_request_id ON audit_log(request_id); + +CREATE TABLE IF NOT EXISTS audit_shell_eval ( + audit_id INTEGER PRIMARY KEY, + cmd TEXT NOT NULL, + cwd TEXT, + shell TEXT NOT NULL, + stdout_b64 TEXT, + stderr_b64 TEXT, + FOREIGN KEY (audit_id) REFERENCES audit_log(id) +); +` + +func OpenAudit(path string) (*Audit, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)") + if err != nil { + return nil, fmt.Errorf("open audit db %s: %w", path, err) + } + if _, err := db.Exec(auditSchema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply audit schema: %w", err) + } + return &Audit{db: db}, nil +} + +func (a *Audit) Close() error { return a.db.Close() } + +// Append registra una invocacion. Devuelve this_hash. +// args raw → SHA256 → args_hash. Hash chain SHA256(prev || canonical). +func (a *Audit) Append(requestID, capability string, args any, exitCode int) (string, error) { + argsBytes, err := json.Marshal(args) + if err != nil { + return "", fmt.Errorf("marshal args: %w", err) + } + argsHash := sha256hex(argsBytes) + + var prevHash string + _ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash) + + ts := time.Now().Unix() + canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode) + thisHash := sha256hex([]byte(canonical)) + + _, err = a.db.Exec( + `INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ts, requestID, capability, argsHash, exitCode, prevHash, thisHash, + ) + if err != nil { + return "", fmt.Errorf("insert audit: %w", err) + } + return thisHash, nil +} + +func sha256hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// ShellEvalRecord is the verbose audit payload for shell.eval invocations. +// stdout / stderr larger than 4KB are gzip+base64 compressed in the DB. +type ShellEvalRecord struct { + Cmd string + CWD string + Shell string + Stdout string + Stderr string +} + +// AppendVerbose registra una invocacion + payload cleartext (cmd/cwd/shell + stdout/stderr) +// para forense. Devuelve this_hash. Linkado via audit_log.id en audit_shell_eval. +func (a *Audit) AppendVerbose(requestID, capability string, args any, exitCode int, rec ShellEvalRecord) (string, error) { + argsBytes, err := json.Marshal(args) + if err != nil { + return "", fmt.Errorf("marshal args: %w", err) + } + argsHash := sha256hex(argsBytes) + + var prevHash string + _ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash) + + ts := time.Now().Unix() + canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode) + thisHash := sha256hex([]byte(canonical)) + + tx, err := a.db.Begin() + if err != nil { + return "", fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.Exec( + `INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ts, requestID, capability, argsHash, exitCode, prevHash, thisHash, + ) + if err != nil { + return "", fmt.Errorf("insert audit_log: %w", err) + } + auditID, err := res.LastInsertId() + if err != nil { + return "", fmt.Errorf("lastinsertid: %w", err) + } + + stdoutEnc, err := encodeMaybeGzip(rec.Stdout) + if err != nil { + return "", fmt.Errorf("encode stdout: %w", err) + } + stderrEnc, err := encodeMaybeGzip(rec.Stderr) + if err != nil { + return "", fmt.Errorf("encode stderr: %w", err) + } + + if _, err := tx.Exec( + `INSERT INTO audit_shell_eval (audit_id, cmd, cwd, shell, stdout_b64, stderr_b64) + VALUES (?, ?, ?, ?, ?, ?)`, + auditID, rec.Cmd, rec.CWD, rec.Shell, stdoutEnc, stderrEnc, + ); err != nil { + return "", fmt.Errorf("insert audit_shell_eval: %w", err) + } + + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("commit tx: %w", err) + } + return thisHash, nil +} + +// encodeMaybeGzip: si len(s) <= 4KB devuelve base64(plain); si supera, gzip+base64. +// Prefijo "gz:" para distinguir. Vacio devuelve "". +func encodeMaybeGzip(s string) (string, error) { + if s == "" { + return "", nil + } + const threshold = 4096 + if len(s) <= threshold { + return "plain:" + base64.StdEncoding.EncodeToString([]byte(s)), nil + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write([]byte(s)); err != nil { + _ = zw.Close() + return "", err + } + if err := zw.Close(); err != nil { + return "", err + } + return "gz:" + base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} diff --git a/build/darwin-arm64/device_agent b/build/darwin-arm64/device_agent new file mode 100755 index 0000000..6806805 Binary files /dev/null and b/build/darwin-arm64/device_agent differ diff --git a/build/linux-amd64/device_agent b/build/linux-amd64/device_agent new file mode 100755 index 0000000..1bef7a0 Binary files /dev/null and b/build/linux-amd64/device_agent differ diff --git a/build/linux-arm64/device_agent b/build/linux-arm64/device_agent new file mode 100755 index 0000000..cd6c1a1 Binary files /dev/null and b/build/linux-arm64/device_agent differ diff --git a/build/windows-amd64/device_agent.exe b/build/windows-amd64/device_agent.exe new file mode 100755 index 0000000..5d834bb Binary files /dev/null and b/build/windows-amd64/device_agent.exe differ diff --git a/build_all.sh b/build_all.sh new file mode 100755 index 0000000..2a6c7be --- /dev/null +++ b/build_all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Cross-compile device_agent para 4 targets. CGO-free (modernc.org/sqlite). +# Output: build/-/device_agent[.exe] +set -euo pipefail + +cd "$(dirname "$0")" +mkdir -p build + +TARGETS=( + "linux/amd64" + "linux/arm64" + "windows/amd64" + "darwin/arm64" +) + +for tgt in "${TARGETS[@]}"; do + GOOS=${tgt%/*} + GOARCH=${tgt#*/} + EXT="" + [ "$GOOS" = "windows" ] && EXT=".exe" + OUT="build/$GOOS-$GOARCH/device_agent$EXT" + mkdir -p "build/$GOOS-$GOARCH" + echo "==> Building $tgt -> $OUT" + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ + go build -ldflags="-s -w" -o "$OUT" . + ls -la "$OUT" +done + +echo "" +echo "All targets built OK." diff --git a/capability.go b/capability.go new file mode 100644 index 0000000..3ca3fd6 --- /dev/null +++ b/capability.go @@ -0,0 +1,326 @@ +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 +} diff --git a/capability_browser.go b/capability_browser.go new file mode 100644 index 0000000..fc55fb5 --- /dev/null +++ b/capability_browser.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "fn-registry/functions/browser" +) + +// runBrowserOp executes a browser CDP operation by connecting to Chrome's +// remote debugging endpoint, dispatching the op, and closing the connection. +// +// Supported ops (passed via args.op): +// - list_tabs → []CdpTab +// - navigate(url) → ok +// - click_text(text, tag?, exact?) → ok +// - evaluate(expression) → string +// - screenshot(filename?) → path saved +// - type_text(selector, text) → ok +// - get_html(selector?) → html +// +// host + port come from manifest. Defaults: 127.0.0.1:9223. +func runBrowserOp(cap *Capability, op string, args map[string]any) (any, int, error) { + host := cap.ChromeCDPHost + if host == "" { + host = "127.0.0.1" + } + port := cap.ChromeCDPPort + if port == 0 { + port = 9223 + } + + // list_tabs uses HTTP only — no websocket. + if op == "list_tabs" { + tabs, err := browser.CdpListTabs(host, port) + if err != nil { + return nil, -1, err + } + return map[string]any{"tabs": tabs}, 0, nil + } + + // launch_chrome bootstraps Chrome with --remote-debugging-port. + // Idempotent: if the port is already serving CDP, ChromeLaunch's + // waitCDPReady connects to the existing process and returns success. + if op == "launch_chrome" { + opts := browser.ChromeLaunchOpts{Port: port} + if hl, ok := args["headless"].(bool); ok { + opts.Headless = hl + } + if udd, ok := args["user_data_dir"].(string); ok && udd != "" { + opts.UserDataDir = udd + } + if cp, ok := args["chrome_path"].(string); ok && cp != "" { + opts.ChromePath = cp + } + pid, err := browser.ChromeLaunch(opts) + if err != nil { + return nil, -1, err + } + return map[string]any{"pid": pid, "port": port}, 0, nil + } + + // Operations that need a websocket connection. + conn, err := browser.CdpConnectHost(host, port) + if err != nil { + return nil, -1, fmt.Errorf("cdp connect %s:%d: %w", host, port, err) + } + defer browser.CdpClose(conn, 0) + + switch op { + case "navigate": + url, _ := args["url"].(string) + if url == "" { + return nil, -1, fmt.Errorf("navigate: url required") + } + if err := browser.CdpNavigate(conn, url); err != nil { + return nil, -1, err + } + return map[string]any{"ok": true, "url": url}, 0, nil + + case "click_text": + text, _ := args["text"].(string) + if text == "" { + return nil, -1, fmt.Errorf("click_text: text required") + } + tag, _ := args["tag"].(string) + exact, _ := args["exact"].(bool) + opts := browser.FindByTextOpts{Tag: tag, Exact: exact} + if err := browser.CdpClickText(conn, text, opts); err != nil { + return nil, -1, err + } + return map[string]any{"ok": true, "clicked": text}, 0, nil + + case "evaluate": + expr, _ := args["expression"].(string) + if expr == "" { + return nil, -1, fmt.Errorf("evaluate: expression required") + } + val, err := browser.CdpEvaluate(conn, expr) + if err != nil { + return nil, -1, err + } + return map[string]any{"value": val}, 0, nil + + case "screenshot": + dir := cap.ScreenshotDir + if dir == "" { + dir = filepath.Join("local_files", "screenshots") + } + fname, _ := args["filename"].(string) + if fname == "" { + fname = fmt.Sprintf("shot_%d.png", time.Now().UnixNano()) + } + fname = strings.ReplaceAll(fname, "..", "_") + out := filepath.Join(dir, fname) + opts := browser.CdpScreenshotOpts{Format: "png"} + if fp, ok := args["full_page"].(bool); ok { + opts.FullPage = fp + } + if err := browser.CdpScreenshot(conn, out, opts); err != nil { + return nil, -1, err + } + return map[string]any{"path": out}, 0, nil + + case "type_text": + text, _ := args["text"].(string) + if text == "" { + return nil, -1, fmt.Errorf("type_text: text required (will be typed into focused element)") + } + if err := browser.CdpTypeText(conn, text); err != nil { + return nil, -1, err + } + return map[string]any{"ok": true}, 0, nil + + case "get_html": + html, err := browser.CdpGetHTML(conn) + if err != nil { + return nil, -1, err + } + return map[string]any{"html": html}, 0, nil + + default: + return nil, -1, fmt.Errorf("browser op %q not supported (list_tabs|navigate|click_text|evaluate|screenshot|type_text|get_html)", op) + } +} diff --git a/capability_docker.go b/capability_docker.go new file mode 100644 index 0000000..2705d68 --- /dev/null +++ b/capability_docker.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// dockerSocketPath ruta del socket docker. Override via env DOCKER_HOST si fuera necesario en el futuro. +var dockerSocketPath = "/var/run/docker.sock" + +// dockerHTTPClient devuelve un *http.Client cuyo Transport dial-tea via unix socket. +// hostOverride opcional: usar otro path (tests con httptest.NewServer sobre unix). +func dockerHTTPClient(sock string) *http.Client { + if sock == "" { + sock = dockerSocketPath + } + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "unix", sock) + }, + }, + } +} + +// dockerHostForReq host falso para HTTP sobre unix. +const dockerHost = "http://localhost" + +// dockerGet hace GET path con query y devuelve body bytes. +func dockerGet(sock, path string, qs url.Values) ([]byte, int, error) { + cli := dockerHTTPClient(sock) + u := dockerHost + path + if len(qs) > 0 { + u += "?" + qs.Encode() + } + resp, err := cli.Get(u) + if err != nil { + return nil, -1, fmt.Errorf("docker GET %s: %w", path, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return body, resp.StatusCode, nil +} + +// dockerPost hace POST path con body JSON (puede ser nil). +func dockerPost(sock, path string, body any) ([]byte, int, error) { + cli := dockerHTTPClient(sock) + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, -1, fmt.Errorf("marshal: %w", err) + } + rdr = strings.NewReader(string(b)) + } + req, err := http.NewRequest("POST", dockerHost+path, rdr) + if err != nil { + return nil, -1, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := cli.Do(req) + if err != nil { + return nil, -1, fmt.Errorf("docker POST %s: %w", path, err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + return rb, resp.StatusCode, nil +} + +// runDockerList wraps GET /containers/json. +func runDockerList(cap *Capability, args map[string]any) (any, int, error) { + _ = cap + qs := url.Values{} + if v, ok := args["all"]; ok { + if b, ok := v.(bool); ok && b { + qs.Set("all", "true") + } + } + if v, ok := args["filters"]; ok && v != nil { + // JSON-encoded filters per docker API + if filtersStr, ok := v.(string); ok { + qs.Set("filters", filtersStr) + } else { + b, _ := json.Marshal(v) + qs.Set("filters", string(b)) + } + } + body, status, err := dockerGet(dockerSocketPath, "/containers/json", qs) + if err != nil { + return nil, -1, err + } + if status >= 400 { + return nil, status, fmt.Errorf("docker api status=%d: %s", status, string(body)) + } + var containers []map[string]any + if err := json.Unmarshal(body, &containers); err != nil { + return nil, -1, fmt.Errorf("decode containers: %w", err) + } + // Slim para no devolver mega payload + slim := []map[string]any{} + for _, c := range containers { + entry := map[string]any{ + "id": truncString(getStr(c, "Id"), 12), + "image": getStr(c, "Image"), + "state": getStr(c, "State"), + "status": getStr(c, "Status"), + } + if names, ok := c["Names"].([]any); ok && len(names) > 0 { + entry["name"] = strings.TrimPrefix(fmt.Sprintf("%v", names[0]), "/") + } + slim = append(slim, entry) + } + return map[string]any{ + "containers": slim, + "count": len(slim), + }, 0, nil +} + +// runDockerLogs GET /containers/{id}/logs?stdout=1&stderr=1&tail=N +func runDockerLogs(cap *Capability, args map[string]any) (any, int, error) { + _ = cap + container := mapStringField(args, "container") + if container == "" { + return nil, -1, fmt.Errorf("container required") + } + tail := mapIntField(args, "tail", 100) + since := mapStringField(args, "since") + qs := url.Values{} + qs.Set("stdout", "1") + qs.Set("stderr", "1") + if tail > 0 { + qs.Set("tail", fmt.Sprintf("%d", tail)) + } + if since != "" { + qs.Set("since", since) + } + body, status, err := dockerGet(dockerSocketPath, "/containers/"+container+"/logs", qs) + if err != nil { + return nil, -1, err + } + if status >= 400 { + return nil, status, fmt.Errorf("docker logs status=%d: %s", status, string(body)) + } + // Body es multiplexed con frame 8-byte header (stream_type|0|0|0|size_be32). + stdout, stderr := demuxDockerStream(body) + // Trunca a 256KB cada uno por defensa + const maxOut = 256 * 1024 + if len(stdout) > maxOut { + stdout = stdout[len(stdout)-maxOut:] + } + if len(stderr) > maxOut { + stderr = stderr[len(stderr)-maxOut:] + } + return map[string]any{ + "container": container, + "stdout": stdout, + "stderr": stderr, + "lines": strings.Split(stdout, "\n"), + "exit_code": 0, + }, 0, nil +} + +// runDockerExec docker exec con whitelist binaries. +// Flujo: POST /containers/{id}/exec -> exec id. POST /exec/{id}/start (Detach=false, Tty=false) -> hijacked stream. +func runDockerExec(cap *Capability, args map[string]any) (any, int, error) { + container := mapStringField(args, "container") + if container == "" { + return nil, -1, fmt.Errorf("container required") + } + var argv []string + if raw, ok := args["argv"]; ok && raw != nil { + if arr, ok := raw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + argv = append(argv, s) + } + } + } + } + if len(argv) == 0 { + return nil, -1, fmt.Errorf("argv required (non-empty array)") + } + if len(cap.BinariesAllowed) == 0 { + return nil, -1, fmt.Errorf("no binaries whitelisted for docker.container.exec") + } + bin := argv[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) + } + + // 1. Crear exec + createBody := map[string]any{ + "Cmd": argv, + "AttachStdout": true, + "AttachStderr": true, + "Tty": false, + } + body, status, err := dockerPost(dockerSocketPath, "/containers/"+container+"/exec", createBody) + if err != nil { + return nil, -1, err + } + if status >= 400 { + return nil, status, fmt.Errorf("docker exec create status=%d: %s", status, string(body)) + } + var created map[string]any + if err := json.Unmarshal(body, &created); err != nil { + return nil, -1, fmt.Errorf("decode exec create: %w", err) + } + execID, _ := created["Id"].(string) + if execID == "" { + return nil, -1, fmt.Errorf("exec create returned empty id") + } + + // 2. Start exec con Detach=false hijacks la conexion. + startBody := map[string]any{"Detach": false, "Tty": false} + streamBody, status, err := dockerPost(dockerSocketPath, "/exec/"+execID+"/start", startBody) + if err != nil { + return nil, -1, err + } + if status >= 400 { + return nil, status, fmt.Errorf("docker exec start status=%d: %s", status, string(streamBody)) + } + stdout, stderr := demuxDockerStream(streamBody) + + // 3. Inspect para obtener exit_code + body2, status, err := dockerGet(dockerSocketPath, "/exec/"+execID+"/json", nil) + exitCode := -1 + if err == nil && status < 400 { + var inspect map[string]any + if json.Unmarshal(body2, &inspect) == nil { + if v, ok := inspect["ExitCode"].(float64); ok { + exitCode = int(v) + } + } + } + + // Trunca outputs + const maxOut = 256 * 1024 + if len(stdout) > maxOut { + stdout = stdout[:maxOut] + } + if len(stderr) > maxOut { + stderr = stderr[:maxOut] + } + + return map[string]any{ + "container": container, + "argv": argv, + "stdout": stdout, + "stderr": stderr, + "exit_code": exitCode, + }, exitCode, nil +} + +// demuxDockerStream parsea stream multiplexed de docker (8-byte header + payload). +// frame: [stream_type][0][0][0][size_be32][payload]. stream_type: 1=stdout 2=stderr. +// Si no parece multiplexed (sin header valido), devuelve todo como stdout. +func demuxDockerStream(b []byte) (string, string) { + var so, se strings.Builder + for len(b) >= 8 { + typ := b[0] + // size big-endian uint32 at offset 4 + size := binary.BigEndian.Uint32(b[4:8]) + if int(size) > len(b)-8 { + // frame corrupto, asumimos plain stdout + so.Write(b[8:]) + return so.String(), se.String() + } + payload := b[8 : 8+size] + switch typ { + case 1: + so.Write(payload) + case 2: + se.Write(payload) + default: + // stdin (0) o stream desconocido -> stdout fallback + so.Write(payload) + } + b = b[8+size:] + } + if len(b) > 0 { + so.Write(b) + } + return so.String(), se.String() +} + +func getStr(m map[string]any, k string) string { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func truncString(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/capability_docker_test.go b/capability_docker_test.go new file mode 100644 index 0000000..cb295a7 --- /dev/null +++ b/capability_docker_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/binary" + "os" + "strings" + "testing" +) + +func skipIfNoDocker(t *testing.T) { + t.Helper() + if _, err := os.Stat(dockerSocketPath); err != nil { + t.Skipf("docker socket %s not accessible", dockerSocketPath) + } +} + +func TestDockerList_Live(t *testing.T) { + skipIfNoDocker(t) + cap := &Capability{Name: "docker.container.list"} + res, code, err := runDockerList(cap, map[string]any{"all": true}) + if err != nil { + t.Fatalf("list: %v", err) + } + if code != 0 { + t.Fatalf("expected code=0 got %d", code) + } + m := res.(map[string]any) + if _, ok := m["containers"]; !ok { + t.Fatalf("missing containers in result") + } +} + +func TestDockerExec_BinaryNotAllowed(t *testing.T) { + cap := &Capability{ + Name: "docker.container.exec", + BinariesAllowed: []string{"ls"}, + } + _, _, err := runDockerExec(cap, map[string]any{ + "container": "any", + "argv": []any{"rm", "-rf", "/"}, + }) + if err == nil || !strings.Contains(err.Error(), "whitelist") { + t.Fatalf("expected whitelist reject, got %v", err) + } +} + +func TestDockerExec_NoArgv(t *testing.T) { + cap := &Capability{Name: "docker.container.exec", BinariesAllowed: []string{"ls"}} + _, _, err := runDockerExec(cap, map[string]any{"container": "x"}) + if err == nil || !strings.Contains(err.Error(), "argv required") { + t.Fatalf("expected argv required, got %v", err) + } +} + +func TestDemuxDockerStream(t *testing.T) { + // Construir un frame stdout "hello" + stderr "err" + mk := func(typ byte, payload string) []byte { + hdr := make([]byte, 8) + hdr[0] = typ + binary.BigEndian.PutUint32(hdr[4:], uint32(len(payload))) + return append(hdr, []byte(payload)...) + } + stream := append(mk(1, "hello"), mk(2, "err")...) + so, se := demuxDockerStream(stream) + if so != "hello" || se != "err" { + t.Fatalf("demux failed: stdout=%q stderr=%q", so, se) + } +} + +func TestDockerLogs_NoContainer(t *testing.T) { + cap := &Capability{Name: "docker.container.logs"} + _, _, err := runDockerLogs(cap, map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "container required") { + t.Fatalf("expected container required, got %v", err) + } +} diff --git a/capability_fs.go b/capability_fs.go new file mode 100644 index 0000000..705b016 --- /dev/null +++ b/capability_fs.go @@ -0,0 +1,264 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + fsDefaultMaxBytes = 64 * 1024 + fsHardMaxBytes = 1024 * 1024 +) + +// isPathAllowed valida path contra una lista de globs declarados en cap.PathsAllowed. +// Aplica EvalSymlinks (best-effort) y filepath.Clean para neutralizar ../ + symlinks. +// Soporta el sufijo "/**" para match recursivo bajo un prefijo. +func isPathAllowed(path string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + abs, err := filepath.Abs(path) + if err != nil { + return false + } + abs = filepath.Clean(abs) + + // Resolve symlinks si el target existe; si no existe (write a archivo nuevo) + // resolvemos el directorio padre, no romper. + resolved := abs + if real, err := filepath.EvalSymlinks(abs); err == nil { + resolved = real + } else { + // Para paths no existentes (write): intenta resolver el dir padre. + dir := filepath.Dir(abs) + base := filepath.Base(abs) + if realDir, err := filepath.EvalSymlinks(dir); err == nil { + resolved = filepath.Join(realDir, base) + } + } + resolved = filepath.Clean(resolved) + + for _, pat := range allowed { + patClean := filepath.Clean(pat) + // Soporte sufijo /** recursivo + if strings.HasSuffix(patClean, string(filepath.Separator)+"**") || strings.HasSuffix(patClean, "/**") { + prefix := strings.TrimSuffix(patClean, "/**") + prefix = strings.TrimSuffix(prefix, string(filepath.Separator)+"**") + prefix = filepath.Clean(prefix) + if resolved == prefix || strings.HasPrefix(resolved, prefix+string(filepath.Separator)) { + return true + } + continue + } + // Si patron es un dir y resolved esta debajo, allow (caso /etc/) + if strings.HasSuffix(pat, "/") { + prefix := filepath.Clean(pat) + if strings.HasPrefix(resolved, prefix+string(filepath.Separator)) || resolved == prefix { + return true + } + } + // Match exacto o glob simple + if match, _ := filepath.Match(patClean, resolved); match { + return true + } + if resolved == patClean { + return true + } + } + return false +} + +// mapIntField extrae un int de map[string]any tolerando float64 (JSON default). +func mapIntField(m map[string]any, key string, def int) int { + if v, ok := m[key]; ok && v != nil { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case int64: + return int(t) + case json.Number: + n, err := t.Int64() + if err == nil { + return int(n) + } + } + } + return def +} + +func mapStringField(m map[string]any, key string) string { + if v, ok := m[key]; ok && v != nil { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// runFsRead lee un archivo y devuelve content_b64 + meta. Trunca a max_bytes. +func runFsRead(cap *Capability, args map[string]any) (any, int, error) { + path := mapStringField(args, "path") + if path == "" { + return nil, -1, fmt.Errorf("path required") + } + maxBytes := mapIntField(args, "max_bytes", fsDefaultMaxBytes) + if maxBytes <= 0 { + maxBytes = fsDefaultMaxBytes + } + if maxBytes > fsHardMaxBytes { + maxBytes = fsHardMaxBytes + } + if !isPathAllowed(path, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) + } + st, err := os.Stat(path) + if err != nil { + return nil, -1, fmt.Errorf("stat: %w", err) + } + if st.IsDir() { + return nil, -1, fmt.Errorf("path is a directory") + } + f, err := os.Open(path) // #nosec G304 — whitelisted above + if err != nil { + return nil, -1, fmt.Errorf("open: %w", err) + } + defer f.Close() + buf := make([]byte, maxBytes) + n, err := f.Read(buf) + if err != nil && err.Error() != "EOF" && n == 0 { + return nil, -1, fmt.Errorf("read: %w", err) + } + truncated := int64(n) < st.Size() + return map[string]any{ + "content_b64": base64.StdEncoding.EncodeToString(buf[:n]), + "size": st.Size(), + "bytes_read": n, + "mtime": st.ModTime().Unix(), + "truncated": truncated, + "path": path, + }, 0, nil +} + +// runFsWrite escribe content_b64 a path. Mkdir parent, default mode 0644. +func runFsWrite(cap *Capability, args map[string]any) (any, int, error) { + path := mapStringField(args, "path") + if path == "" { + return nil, -1, fmt.Errorf("path required") + } + contentB64 := mapStringField(args, "content_b64") + if contentB64 == "" { + // Soporte fallback "content" plano + if c := mapStringField(args, "content"); c != "" { + contentB64 = base64.StdEncoding.EncodeToString([]byte(c)) + } + } + mode := mapIntField(args, "mode", 0644) + if !isPathAllowed(path, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) + } + data, err := base64.StdEncoding.DecodeString(contentB64) + if err != nil { + return nil, -1, fmt.Errorf("invalid content_b64: %w", err) + } + if int64(len(data)) > fsHardMaxBytes { + return nil, -1, fmt.Errorf("content too large (>1MB)") + } + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0755); err != nil { + return nil, -1, fmt.Errorf("mkdir parent: %w", err) + } + if err := os.WriteFile(path, data, os.FileMode(mode)); err != nil { // #nosec G306 + return nil, -1, fmt.Errorf("write: %w", err) + } + return map[string]any{ + "path": path, + "bytes_written": len(data), + }, 0, nil +} + +// runFsList lista un directorio (no recursivo). glob opcional filtra entries. +func runFsList(cap *Capability, args map[string]any) (any, int, error) { + dir := mapStringField(args, "dir") + if dir == "" { + return nil, -1, fmt.Errorf("dir required") + } + glob := mapStringField(args, "glob") + if !isPathAllowed(dir, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("dir not allowed by manifest: %s", dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, -1, fmt.Errorf("readdir: %w", err) + } + out := []map[string]any{} + for _, e := range entries { + if glob != "" { + if m, _ := filepath.Match(glob, e.Name()); !m { + continue + } + } + info, ierr := e.Info() + kind := "file" + var size int64 + var mtime int64 + if ierr == nil { + if info.Mode()&os.ModeSymlink != 0 { + kind = "symlink" + } else if info.IsDir() { + kind = "dir" + } + size = info.Size() + mtime = info.ModTime().Unix() + } + out = append(out, map[string]any{ + "name": e.Name(), + "kind": kind, + "size": size, + "mtime": mtime, + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i]["name"].(string) < out[j]["name"].(string) + }) + return map[string]any{ + "dir": dir, + "entries": out, + "count": len(out), + }, 0, nil +} + +// runFsStat devuelve metadata de un archivo o directorio. +func runFsStat(cap *Capability, args map[string]any) (any, int, error) { + path := mapStringField(args, "path") + if path == "" { + return nil, -1, fmt.Errorf("path required") + } + if !isPathAllowed(path, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("path not allowed by manifest: %s", path) + } + st, err := os.Lstat(path) + if err != nil { + return nil, -1, fmt.Errorf("stat: %w", err) + } + kind := "file" + if st.IsDir() { + kind = "dir" + } else if st.Mode()&os.ModeSymlink != 0 { + kind = "symlink" + } + res := map[string]any{ + "path": path, + "kind": kind, + "size": st.Size(), + "mode": fmt.Sprintf("%#o", st.Mode().Perm()), + "mtime": st.ModTime().Unix(), + } + return res, 0, nil +} diff --git a/capability_fs_test.go b/capability_fs_test.go new file mode 100644 index 0000000..6ed74f8 --- /dev/null +++ b/capability_fs_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func tempCap(t *testing.T, allowed ...string) *Capability { + t.Helper() + return &Capability{ + Name: "fs.test", + PathsAllowed: allowed, + } +} + +func TestFsRead_PathNotAllowed(t *testing.T) { + tmp := t.TempDir() + cap := tempCap(t, tmp+"/**") + _, _, err := runFsRead(cap, map[string]any{"path": "/etc/passwd"}) + if err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected not allowed, got: %v", err) + } +} + +func TestFsRead_PathAllowed(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "test.txt") + if err := os.WriteFile(target, []byte("hello world"), 0644); err != nil { + t.Fatal(err) + } + cap := tempCap(t, tmp+"/**") + res, code, err := runFsRead(cap, map[string]any{"path": target}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("expected exit=0 got %d", code) + } + m := res.(map[string]any) + b, _ := base64.StdEncoding.DecodeString(m["content_b64"].(string)) + if string(b) != "hello world" { + t.Fatalf("content mismatch: %q", string(b)) + } +} + +func TestFsRead_MaxBytesTrunca(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "big.bin") + if err := os.WriteFile(target, make([]byte, 1000), 0644); err != nil { + t.Fatal(err) + } + cap := tempCap(t, tmp+"/**") + res, _, err := runFsRead(cap, map[string]any{"path": target, "max_bytes": 100}) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]any) + if !m["truncated"].(bool) { + t.Fatalf("expected truncated=true") + } + if m["bytes_read"].(int) != 100 { + t.Fatalf("expected 100 bytes, got %v", m["bytes_read"]) + } +} + +func TestFsWrite_CreaParent(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "subdir", "nested", "x.txt") + cap := tempCap(t, tmp+"/**") + body := base64.StdEncoding.EncodeToString([]byte("payload")) + _, _, err := runFsWrite(cap, map[string]any{"path": target, "content_b64": body}) + if err != nil { + t.Fatalf("write: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "payload" { + t.Fatalf("content mismatch: %q", string(got)) + } +} + +func TestFsList_NoRecursive(t *testing.T) { + tmp := t.TempDir() + for _, n := range []string{"a.txt", "b.txt", "c.txt"} { + os.WriteFile(filepath.Join(tmp, n), []byte("x"), 0644) + } + os.Mkdir(filepath.Join(tmp, "subdir"), 0755) + // archivo en subdir NO debe aparecer (no recursivo) + os.WriteFile(filepath.Join(tmp, "subdir", "hidden.txt"), []byte("y"), 0644) + + cap := tempCap(t, tmp+"/**") + res, _, err := runFsList(cap, map[string]any{"dir": tmp}) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]any) + entries := m["entries"].([]map[string]any) + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d: %+v", len(entries), entries) + } + // Check kind correcto: subdir es dir + for _, e := range entries { + if e["name"] == "subdir" && e["kind"] != "dir" { + t.Fatalf("subdir kind != dir: %v", e) + } + } +} + +func TestFsStat_OK(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "z.txt") + os.WriteFile(target, []byte("abc"), 0644) + cap := tempCap(t, tmp+"/**") + res, _, err := runFsStat(cap, map[string]any{"path": target}) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]any) + if m["kind"] != "file" { + t.Fatalf("kind: %v", m["kind"]) + } + if m["size"].(int64) != 3 { + t.Fatalf("size: %v", m["size"]) + } +} + +func TestPathTraversal_DotDot(t *testing.T) { + tmp := t.TempDir() + cap := tempCap(t, tmp+"/**") + traversal := filepath.Join(tmp, "..", "..", "..", "etc", "passwd") + _, _, err := runFsRead(cap, map[string]any{"path": traversal}) + if err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected blocked traversal, got err=%v", err) + } +} + +func TestPathTraversal_Symlink(t *testing.T) { + tmp := t.TempDir() + // crear symlink dentro de tmp apuntando a /etc/passwd + linkPath := filepath.Join(tmp, "leak") + if err := os.Symlink("/etc/passwd", linkPath); err != nil { + t.Skipf("symlink failed: %v", err) + } + cap := tempCap(t, tmp+"/**") + _, _, err := runFsRead(cap, map[string]any{"path": linkPath}) + // EvalSymlinks lo resuelve a /etc/passwd que NO esta en allowed -> reject + if err == nil { + t.Fatalf("expected symlink leak rejected") + } +} + +func TestFsList_Glob(t *testing.T) { + tmp := t.TempDir() + for _, n := range []string{"a.log", "b.log", "c.txt"} { + os.WriteFile(filepath.Join(tmp, n), []byte("x"), 0644) + } + cap := tempCap(t, tmp+"/**") + res, _, err := runFsList(cap, map[string]any{"dir": tmp, "glob": "*.log"}) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]any) + entries := m["entries"].([]map[string]any) + if len(entries) != 2 { + t.Fatalf("expected 2 .log, got %d", len(entries)) + } +} diff --git a/capability_git.go b/capability_git.go new file mode 100644 index 0000000..1f79a6a --- /dev/null +++ b/capability_git.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +const gitTimeoutSeconds = 120 + +// gitRun ejecuta git con argumentos en cwd. Devuelve stdout, stderr, exitCode. +func gitRun(cwd string, args ...string) (string, string, int, error) { + if _, err := exec.LookPath("git"); err != nil { + return "", "", -1, fmt.Errorf("git binary not in PATH") + } + ctx, cancel := context.WithTimeout(context.Background(), gitTimeoutSeconds*time.Second) + defer cancel() + c := exec.CommandContext(ctx, "git", args...) // #nosec G204 — args controlled + if cwd != "" { + c.Dir = cwd + } + var stdout, stderr strings.Builder + c.Stdout = &stdout + c.Stderr = &stderr + err := c.Run() + exitCode := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } else { + return stdout.String(), stderr.String(), -1, err + } + } + return stdout.String(), stderr.String(), exitCode, nil +} + +// runGitClone clona un repo a dest. Valida dest contra cap.PathsAllowed. +func runGitClone(cap *Capability, args map[string]any) (any, int, error) { + url := mapStringField(args, "url") + dest := mapStringField(args, "dest") + if url == "" || dest == "" { + return nil, -1, fmt.Errorf("url and dest required") + } + if !isPathAllowed(dest, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("dest not allowed by manifest: %s", dest) + } + stdout, stderr, code, err := gitRun("", "clone", url, dest) + if err != nil { + return nil, -1, fmt.Errorf("git clone: %w (%s)", err, stderr) + } + if code != 0 { + return map[string]any{ + "stdout": stdout, + "stderr": stderr, + "exit_code": code, + }, code, fmt.Errorf("git clone exit=%d: %s", code, stderr) + } + // recoge HEAD commit + branch + sha, _, _, _ := gitRun(dest, "rev-parse", "HEAD") + branch, _, _, _ := gitRun(dest, "rev-parse", "--abbrev-ref", "HEAD") + return map[string]any{ + "dest": dest, + "commit_sha": strings.TrimSpace(sha), + "branch": strings.TrimSpace(branch), + "stdout": stdout, + "exit_code": 0, + }, 0, nil +} + +// runGitCommit hace git add + commit en repo. files opcional; vacio = -am. +func runGitCommit(cap *Capability, args map[string]any) (any, int, error) { + repo := mapStringField(args, "repo") + msg := mapStringField(args, "message") + if repo == "" || msg == "" { + return nil, -1, fmt.Errorf("repo and message required") + } + if !isPathAllowed(repo, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("repo not allowed by manifest: %s", repo) + } + var files []string + if raw, ok := args["files"]; ok && raw != nil { + if arr, ok := raw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + files = append(files, s) + } + } + } + } + if len(files) > 0 { + addArgs := append([]string{"add", "--"}, files...) + _, addStderr, code, err := gitRun(repo, addArgs...) + if err != nil || code != 0 { + return nil, code, fmt.Errorf("git add: %s", addStderr) + } + _, cStderr, code, err := gitRun(repo, "commit", "-m", msg) + if err != nil || code != 0 { + return map[string]any{"stderr": cStderr, "exit_code": code}, code, fmt.Errorf("git commit exit=%d: %s", code, cStderr) + } + } else { + _, cStderr, code, err := gitRun(repo, "commit", "-am", msg) + if err != nil || code != 0 { + return map[string]any{"stderr": cStderr, "exit_code": code}, code, fmt.Errorf("git commit exit=%d: %s", code, cStderr) + } + } + sha, _, _, _ := gitRun(repo, "rev-parse", "HEAD") + return map[string]any{ + "repo": repo, + "commit_sha": strings.TrimSpace(sha), + "exit_code": 0, + }, 0, nil +} + +// runGitPush push del repo. remote default "origin", branch default "HEAD". +func runGitPush(cap *Capability, args map[string]any) (any, int, error) { + repo := mapStringField(args, "repo") + if repo == "" { + return nil, -1, fmt.Errorf("repo required") + } + if !isPathAllowed(repo, cap.PathsAllowed) { + return nil, -1, fmt.Errorf("repo not allowed by manifest: %s", repo) + } + remote := mapStringField(args, "remote") + if remote == "" { + remote = "origin" + } + branch := mapStringField(args, "branch") + if branch == "" { + branch = "HEAD" + } + stdout, stderr, code, err := gitRun(repo, "push", remote, branch) + if err != nil { + return nil, -1, fmt.Errorf("git push: %w (%s)", err, stderr) + } + if code != 0 { + return map[string]any{ + "stdout": stdout, + "stderr": stderr, + "exit_code": code, + }, code, fmt.Errorf("git push exit=%d: %s", code, stderr) + } + return map[string]any{ + "ok": true, + "remote": remote, + "branch": branch, + "stdout": stdout, + "stderr": stderr, + "exit_code": 0, + }, 0, nil +} diff --git a/capability_git_test.go b/capability_git_test.go new file mode 100644 index 0000000..4537c00 --- /dev/null +++ b/capability_git_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func skipIfNoGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +func initRepo(t *testing.T, dir string) { + t.Helper() + mustRun := func(args ...string) { + c := exec.Command("git", args...) + c.Dir = dir + c.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@x", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@x") + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + mustRun("init", "-b", "master") + mustRun("config", "user.email", "test@x") + mustRun("config", "user.name", "test") +} + +func TestGitCommit_OK(t *testing.T) { + skipIfNoGit(t) + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + os.MkdirAll(repo, 0755) + initRepo(t, repo) + os.WriteFile(filepath.Join(repo, "f.txt"), []byte("hello"), 0644) + + cap := tempCap(t, tmp+"/**") + res, _, err := runGitCommit(cap, map[string]any{ + "repo": repo, + "message": "init", + "files": []any{"f.txt"}, + }) + if err != nil { + t.Fatalf("commit: %v", err) + } + m := res.(map[string]any) + if m["commit_sha"].(string) == "" { + t.Fatalf("empty sha") + } +} + +func TestGitCommit_PathNotAllowed(t *testing.T) { + skipIfNoGit(t) + cap := tempCap(t, "/tmp/safe/**") + _, _, err := runGitCommit(cap, map[string]any{ + "repo": "/etc", + "message": "evil", + }) + if err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected reject, got %v", err) + } +} + +func TestGitClone_Local(t *testing.T) { + skipIfNoGit(t) + tmp := t.TempDir() + src := filepath.Join(tmp, "src") + os.MkdirAll(src, 0755) + initRepo(t, src) + os.WriteFile(filepath.Join(src, "f.txt"), []byte("data"), 0644) + c := exec.Command("git", "add", ".") + c.Dir = src + c.Run() + c = exec.Command("git", "commit", "-m", "init") + c.Dir = src + c.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x") + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("seed commit: %v %s", err, out) + } + + dest := filepath.Join(tmp, "clone") + cap := tempCap(t, tmp+"/**") + res, code, err := runGitClone(cap, map[string]any{ + "url": src, + "dest": dest, + }) + if err != nil { + t.Fatalf("clone: %v code=%d", err, code) + } + m := res.(map[string]any) + if m["commit_sha"].(string) == "" { + t.Fatalf("empty sha") + } + if _, err := os.Stat(filepath.Join(dest, "f.txt")); err != nil { + t.Fatalf("file not present after clone") + } +} + +func TestGitPush_PathNotAllowed(t *testing.T) { + skipIfNoGit(t) + cap := tempCap(t, "/tmp/safe/**") + _, _, err := runGitPush(cap, map[string]any{"repo": "/etc"}) + if err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected reject, got %v", err) + } +} diff --git a/capability_pkg.go b/capability_pkg.go new file mode 100644 index 0000000..c3f9cdf --- /dev/null +++ b/capability_pkg.go @@ -0,0 +1,146 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const pkgMaxResults = 50 + +// detectDistro lee /etc/os-release y devuelve ID (ubuntu, debian, fedora, arch, ...). +func detectDistro() string { + f, err := os.Open("/etc/os-release") + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "ID=") { + return strings.Trim(strings.TrimPrefix(line, "ID="), `"`) + } + } + return "" +} + +// runPkgSearch busca paquetes. Detect distro y delega a apt/dnf/pacman. +// Permite override via env PKG_FAKE_OUTPUT (for tests). +func runPkgSearch(cap *Capability, args map[string]any) (any, int, error) { + _ = cap + query := mapStringField(args, "query") + if query == "" { + return nil, -1, fmt.Errorf("query required") + } + // Validar query: solo alfanumerico + . + _ + - + for _, r := range query { + if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && + !(r >= '0' && r <= '9') && r != '.' && r != '_' && r != '-' && r != '+' { + return nil, -1, fmt.Errorf("invalid char in query: %q", query) + } + } + + // Test fake output via env + if fake := os.Getenv("PKG_FAKE_OUTPUT"); fake != "" { + return parsePkgOutput(fake, "apt", query), 0, nil + } + + distro := detectDistro() + var bin string + var argv []string + switch distro { + case "ubuntu", "debian": + bin = "apt-cache" + argv = []string{"search", query} + case "fedora", "rhel", "centos": + bin = "dnf" + argv = []string{"search", "--quiet", query} + case "arch", "manjaro": + bin = "pacman" + argv = []string{"-Ss", query} + default: + // fallback try apt-cache + if _, err := exec.LookPath("apt-cache"); err == nil { + bin = "apt-cache" + argv = []string{"search", query} + distro = "ubuntu" + } else { + return nil, -1, fmt.Errorf("unsupported distro %q (no pkg manager found)", distro) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + c := exec.CommandContext(ctx, bin, argv...) // #nosec G204 — query validated + out, err := c.Output() + if err != nil { + return nil, -1, fmt.Errorf("%s search: %w", bin, err) + } + return parsePkgOutput(string(out), distro, query), 0, nil +} + +// parsePkgOutput parsea salida apt-cache search / dnf search / pacman -Ss. +func parsePkgOutput(out, distro, query string) map[string]any { + packages := []map[string]any{} + lines := strings.Split(out, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var name, desc string + switch distro { + case "ubuntu", "debian", "apt": + // "name - description" + idx := strings.Index(line, " - ") + if idx > 0 { + name = line[:idx] + desc = line[idx+3:] + } else { + name = line + } + case "arch", "manjaro": + // "repo/name version" then next line " desc" + if strings.HasPrefix(line, " ") { + if len(packages) > 0 { + packages[len(packages)-1]["description"] = strings.TrimSpace(line) + } + continue + } + parts := strings.Fields(line) + if len(parts) > 0 { + name = parts[0] + } + default: + // dnf: "name.arch : description" + if idx := strings.Index(line, " : "); idx > 0 { + name = strings.TrimSpace(line[:idx]) + desc = strings.TrimSpace(line[idx+3:]) + } else { + name = line + } + } + if name == "" { + continue + } + packages = append(packages, map[string]any{ + "name": name, + "description": desc, + }) + if len(packages) >= pkgMaxResults { + break + } + } + return map[string]any{ + "query": query, + "distro": distro, + "packages": packages, + "count": len(packages), + "truncated": len(packages) >= pkgMaxResults, + } +} diff --git a/capability_pkg_test.go b/capability_pkg_test.go new file mode 100644 index 0000000..83c085c --- /dev/null +++ b/capability_pkg_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestPkgSearch_FakeOutput(t *testing.T) { + fake := "vim - Vi IMproved - enhanced vi editor\nvim-tiny - Vi IMproved - enhanced vi editor - compact version" + os.Setenv("PKG_FAKE_OUTPUT", fake) + defer os.Unsetenv("PKG_FAKE_OUTPUT") + + cap := &Capability{Name: "pkg.search"} + res, code, err := runPkgSearch(cap, map[string]any{"query": "vim"}) + if err != nil { + t.Fatalf("search: %v", err) + } + if code != 0 { + t.Fatalf("code: %d", code) + } + m := res.(map[string]any) + pkgs := m["packages"].([]map[string]any) + if len(pkgs) != 2 { + t.Fatalf("expected 2 pkgs, got %d", len(pkgs)) + } + if pkgs[0]["name"] != "vim" { + t.Fatalf("name mismatch: %v", pkgs[0]["name"]) + } +} + +func TestPkgSearch_InvalidQuery(t *testing.T) { + cap := &Capability{Name: "pkg.search"} + _, _, err := runPkgSearch(cap, map[string]any{"query": "vim; rm -rf /"}) + if err == nil || !strings.Contains(err.Error(), "invalid char") { + t.Fatalf("expected invalid char, got %v", err) + } +} + +func TestPkgSearch_NoQuery(t *testing.T) { + cap := &Capability{Name: "pkg.search"} + _, _, err := runPkgSearch(cap, map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "query required") { + t.Fatalf("expected query required, got %v", err) + } +} diff --git a/capability_proc.go b/capability_proc.go new file mode 100644 index 0000000..8b7e54d --- /dev/null +++ b/capability_proc.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +const procMaxEntries = 200 + +// runProcList enumera procesos. En linux usa /proc parsing via `ps -eo`. +func runProcList(cap *Capability, args map[string]any) (any, int, error) { + _ = cap + _ = args + if runtime.GOOS == "windows" { + return runProcListWindows() + } + return runProcListUnix() +} + +func runProcListUnix() (any, int, error) { + if _, err := exec.LookPath("ps"); err != nil { + return nil, -1, fmt.Errorf("ps binary not found") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // ps -eo pid,ppid,user,comm,rss,pcpu --no-headers + c := exec.CommandContext(ctx, "ps", "-eo", "pid,ppid,user,comm,rss,pcpu", "--no-headers") + out, err := c.Output() + if err != nil { + return nil, -1, fmt.Errorf("ps: %w", err) + } + lines := strings.Split(string(out), "\n") + processes := []map[string]any{} + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + pid, _ := strconv.Atoi(fields[0]) + ppid, _ := strconv.Atoi(fields[1]) + user := fields[2] + name := fields[3] + rss, _ := strconv.ParseInt(fields[4], 10, 64) + cpu, _ := strconv.ParseFloat(fields[5], 64) + processes = append(processes, map[string]any{ + "pid": pid, + "ppid": ppid, + "user": user, + "name": name, + "rss_kb": rss, + "cpu_pct": cpu, + }) + if len(processes) >= procMaxEntries { + break + } + } + return map[string]any{ + "processes": processes, + "count": len(processes), + "truncated": len(processes) >= procMaxEntries, + }, 0, nil +} + +func runProcListWindows() (any, int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + c := exec.CommandContext(ctx, "tasklist", "/FO", "CSV", "/NH") + out, err := c.Output() + if err != nil { + return nil, -1, fmt.Errorf("tasklist: %w", err) + } + lines := strings.Split(string(out), "\n") + processes := []map[string]any{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) < 5 { + continue + } + name := strings.Trim(parts[0], `"`) + pid, _ := strconv.Atoi(strings.Trim(parts[1], `"`)) + processes = append(processes, map[string]any{ + "pid": pid, + "name": name, + }) + if len(processes) >= procMaxEntries { + break + } + } + return map[string]any{ + "processes": processes, + "count": len(processes), + "truncated": len(processes) >= procMaxEntries, + }, 0, nil +} diff --git a/capability_proc_test.go b/capability_proc_test.go new file mode 100644 index 0000000..c7841a5 --- /dev/null +++ b/capability_proc_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "testing" +) + +func TestProcList_NonEmpty(t *testing.T) { + cap := &Capability{Name: "proc.list"} + res, code, err := runProcList(cap, map[string]any{}) + if err != nil { + t.Fatalf("proc.list: %v", err) + } + if code != 0 { + t.Fatalf("expected code=0 got %d", code) + } + m := res.(map[string]any) + procs := m["processes"].([]map[string]any) + if len(procs) == 0 { + t.Fatalf("expected processes > 0") + } + for _, p := range procs { + if p["pid"].(int) <= 0 { + t.Fatalf("invalid pid: %v", p) + } + } +} diff --git a/capability_shell_eval.go b/capability_shell_eval.go new file mode 100644 index 0000000..0589723 --- /dev/null +++ b/capability_shell_eval.go @@ -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 +} diff --git a/capability_shell_eval_test.go b/capability_shell_eval_test.go new file mode 100644 index 0000000..ef61c55 --- /dev/null +++ b/capability_shell_eval_test.go @@ -0,0 +1,297 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// setupTestEnv crea un dir temporal, redirige approvalQueuePath alli, y devuelve cleanup. +func setupTestEnv(t *testing.T) (string, func()) { + t.Helper() + tmp, err := os.MkdirTemp("", "device_agent_test_*") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + prevWD, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + _ = os.RemoveAll(tmp) + t.Fatalf("chdir: %v", err) + } + prevApprovalPath := approvalQueuePath + approvalQueuePath = filepath.Join(tmp, "local_files", "approval_queue.jsonl") + cleanup := func() { + approvalQueuePath = prevApprovalPath + _ = os.Chdir(prevWD) + _ = os.RemoveAll(tmp) + } + return tmp, cleanup +} + +// TestShellEval_BlocklistRmRf comprueba que rm -rf / es rechazado sin ejecucion. +func TestShellEval_BlocklistRmRf(t *testing.T) { + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{Name: "shell.eval"} + _, _, _, err := runShellEval(cap, map[string]any{"cmd": "rm -rf /"}) + if err == nil { + t.Fatal("expected error from blocklist, got nil") + } + if !strings.Contains(err.Error(), "blocked") { + t.Fatalf("expected 'blocked' in err, got: %v", err) + } +} + +// TestShellEval_BlocklistCaseInsensitive comprueba que case-insensitive funciona. +func TestShellEval_BlocklistCaseInsensitive(t *testing.T) { + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{Name: "shell.eval"} + _, _, _, err := runShellEval(cap, map[string]any{"cmd": "RM -RF /"}) + if err == nil { + t.Fatal("expected error from blocklist (case insensitive), got nil") + } + if !strings.Contains(err.Error(), "blocked") { + t.Fatalf("expected 'blocked' in err, got: %v", err) + } +} + +// TestShellEval_AutoApproveGitStatus comprueba que git status entra en defaultAutoApprove. +func TestShellEval_AutoApproveGitStatus(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash test on windows skipped") + } + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{Name: "shell.eval"} + res, exit, extra, err := runShellEval(cap, map[string]any{"cmd": "git status --porcelain"}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if res["approval_status"] != "auto-approved" { + t.Fatalf("expected approval_status=auto-approved, got: %v", res["approval_status"]) + } + // git might exit non-zero if not in a repo, but it should have *executed* + if extra == nil { + t.Fatal("expected extra audit record, got nil") + } + if extra.Cmd != "git status --porcelain" { + t.Fatalf("extra.Cmd mismatch: %q", extra.Cmd) + } + _ = exit +} + +// TestShellEval_QueuedWhenRequiresApproval comprueba que un cmd no auto + requires_approval = queued + error. +func TestShellEval_QueuedWhenRequiresApproval(t *testing.T) { + tmp, cleanup := setupTestEnv(t) + defer cleanup() + + cap := &Capability{ + Name: "shell.eval", + RequiresApproval: true, + AutoApprove: []string{`^echo\s`}, // override defaults — solo echo entra + } + _, _, _, err := runShellEval(cap, map[string]any{"cmd": "ls -la"}) + if err == nil { + t.Fatal("expected approval_required error, got nil") + } + if !strings.Contains(err.Error(), "approval_required") { + t.Fatalf("expected 'approval_required' in err, got: %v", err) + } + // queue file existe + contiene la entry + qPath := filepath.Join(tmp, "local_files", "approval_queue.jsonl") + data, ferr := os.ReadFile(qPath) + if ferr != nil { + t.Fatalf("expected approval queue file, got: %v", ferr) + } + if !strings.Contains(string(data), `"cmd":"ls -la"`) { + t.Fatalf("approval queue missing cmd, got: %s", data) + } + if !strings.Contains(string(data), `"status":"pending"`) { + t.Fatalf("approval queue missing status=pending, got: %s", data) + } +} + +// TestShellEval_NoApprovalNeededExecutes: cmd no auto + no requires_approval -> ejecuta directo. +func TestShellEval_NoApprovalNeededExecutes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash test on windows skipped") + } + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{ + Name: "shell.eval", + RequiresApproval: false, + AutoApprove: []string{`^echo\s`}, // strict autoapprove (irrelevant since no approval needed) + } + res, _, extra, err := runShellEval(cap, map[string]any{"cmd": "printf hello"}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if res["approval_status"] != "none-required" { + t.Fatalf("expected approval_status=none-required, got: %v", res["approval_status"]) + } + if extra.Stdout != "hello" { + t.Fatalf("expected stdout 'hello', got: %q", extra.Stdout) + } +} + +// TestShellEval_Timeout: sleep 5 con TimeoutSeconds=1 -> exit_code != 0. +func TestShellEval_Timeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash test on windows skipped") + } + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{ + Name: "shell.eval", + TimeoutSeconds: 1, + AutoApprove: []string{`^sleep\s`}, // autoapprove sleep for the test + } + res, _, _, err := runShellEval(cap, map[string]any{"cmd": "sleep 5"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + exit, _ := res["exit_code"].(int) + if exit == 0 { + t.Fatalf("expected non-zero exit on timeout, got 0; result: %+v", res) + } +} + +// TestShellEval_OutputTruncation: output > MaxOutputBytes -> truncated=true. +func TestShellEval_OutputTruncation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash test on windows skipped") + } + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{ + Name: "shell.eval", + MaxOutputBytes: 50, + AutoApprove: []string{`^printf`}, + } + // produce > 50 bytes + res, _, _, err := runShellEval(cap, map[string]any{ + "cmd": `printf '%0.s-' {1..500}`, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res["truncated"] != true { + t.Fatalf("expected truncated=true, got: %v (full result %+v)", res["truncated"], res) + } +} + +// TestShellEval_PowershellOnLinuxWithoutPwsh: si pwsh no esta en PATH y se pide powershell, error. +func TestShellEval_PowershellOnLinuxWithoutPwsh(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test only meaningful on non-windows") + } + // Save PATH and clear it to ensure pwsh not found + origPATH := os.Getenv("PATH") + defer os.Setenv("PATH", origPATH) + os.Setenv("PATH", "/nonexistent") + + _, cleanup := setupTestEnv(t) + defer cleanup() + cap := &Capability{Name: "shell.eval"} + _, _, _, err := runShellEval(cap, map[string]any{ + "cmd": "echo hi", + "shell": "powershell", + }) + if err == nil { + t.Fatal("expected error when powershell requested on linux without pwsh") + } + if !strings.Contains(err.Error(), "powershell") && !strings.Contains(err.Error(), "pwsh") { + t.Fatalf("err msg doesn't mention powershell/pwsh: %v", err) + } +} + +// TestShellEval_AuditVerboseWritesCleartext: tras una exec, audit_shell_eval tiene row con cmd en claro. +func TestShellEval_AuditVerboseWritesCleartext(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bash test on windows skipped") + } + tmp, cleanup := setupTestEnv(t) + defer cleanup() + + auditPath := filepath.Join(tmp, "audit.db") + audit, err := OpenAudit(auditPath) + if err != nil { + t.Fatalf("open audit: %v", err) + } + defer audit.Close() + + cap := &Capability{ + Name: "shell.eval", + AutoApprove: []string{`^printf`}, + } + _, exitCode, extra, err := runShellEval(cap, map[string]any{"cmd": "printf forensic-cleartext"}) + if err != nil { + t.Fatalf("runShellEval: %v", err) + } + if extra == nil { + t.Fatal("expected extra record") + } + _, err = audit.AppendVerbose("req-1", "shell.eval", []string{"printf forensic-cleartext"}, exitCode, *extra) + if err != nil { + t.Fatalf("AppendVerbose: %v", err) + } + + // Read back + rows, err := audit.db.Query(`SELECT cmd, shell, stdout_b64 FROM audit_shell_eval`) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + found := false + for rows.Next() { + var cmd, shell, stdoutB64 string + if err := rows.Scan(&cmd, &shell, &stdoutB64); err != nil { + t.Fatalf("scan: %v", err) + } + if cmd != "printf forensic-cleartext" { + t.Fatalf("expected cleartext cmd, got: %q", cmd) + } + if !strings.HasPrefix(stdoutB64, "plain:") { + t.Fatalf("expected plain-base64 stdout (short), got: %q", stdoutB64) + } + // Decode + verify + raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(stdoutB64, "plain:")) + if string(raw) != "forensic-cleartext" { + t.Fatalf("decoded stdout mismatch: %q", raw) + } + _ = shell + found = true + } + if !found { + t.Fatal("expected at least one row in audit_shell_eval") + } +} + +// TestShellEval_ParseArgsJSONObject roundtrip. +func TestShellEval_ParseArgsJSONObject(t *testing.T) { + jsonStr, _ := json.Marshal(map[string]any{"cmd": "ls", "cwd": "/tmp"}) + m, err := parseShellEvalArgs([]string{string(jsonStr)}) + if err != nil { + t.Fatalf("parse: %v", err) + } + if m["cmd"] != "ls" || m["cwd"] != "/tmp" { + t.Fatalf("unexpected parsed: %+v", m) + } +} + +// TestShellEval_ParseArgsPositional fallback. +func TestShellEval_ParseArgsPositional(t *testing.T) { + m, err := parseShellEvalArgs([]string{"ls -la", "/tmp", "bash"}) + if err != nil { + t.Fatalf("parse: %v", err) + } + if m["cmd"] != "ls -la" || m["cwd"] != "/tmp" || m["shell"] != "bash" { + t.Fatalf("unexpected parsed: %+v", m) + } +} diff --git a/device_agent b/device_agent new file mode 100755 index 0000000..b831b00 Binary files /dev/null and b/device_agent differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a7da3a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module dataforge/device_agent + +go 1.25.0 + +require ( + fn-registry v0.0.0-00010101000000-000000000000 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.50.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) + +replace fn-registry => ../../../.. diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1fa08b0 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..19e488b --- /dev/null +++ b/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +const Version = "0.2.0" + +func main() { + listen := flag.String("listen", "127.0.0.1:7474", "host:port a escuchar (ej. 10.42.0.10:7474 para mesh wg1)") + manifestPath := flag.String("manifest", defaultManifestPath(), "ruta al manifest YAML") + auditPath := flag.String("audit", "local_files/audit.db", "ruta al audit.db sqlite") + selfTest := flag.Bool("self-test", false, "smoke check, sale 0 si todo OK") + flag.Parse() + + if *selfTest { + fmt.Println("device_agent self-test OK") + os.Exit(0) + } + + // Cargar manifest. + mf, err := LoadManifest(*manifestPath) + if err != nil { + log.Fatalf("manifest load %s: %v", *manifestPath, err) + } + log.Printf("manifest loaded device_id=%s capabilities=%d", mf.DeviceID, len(mf.Capabilities)) + + // Abrir audit DB. + _ = os.MkdirAll(filepath.Dir(*auditPath), 0700) + audit, err := OpenAudit(*auditPath) + if err != nil { + log.Fatalf("audit open %s: %v", *auditPath, err) + } + defer audit.Close() + log.Printf("audit db opened %s", *auditPath) + + // Construir router. + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "device_id": mf.DeviceID, + "version": Version, + "ts": time.Now().Unix(), + }) + }) + mux.HandleFunc("/capabilities", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "device_id": mf.DeviceID, + "capabilities": mf.Capabilities, + }) + }) + mux.HandleFunc("/capability", capabilityHandler(mf, audit)) + + srv := &http.Server{ + Addr: *listen, + Handler: loggingMiddleware(mux), + ReadHeaderTimeout: 5 * time.Second, + } + + // Listen explicit para detectar bind issues claro. + ln, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("listen %s: %v", *listen, err) + } + log.Printf("device_agent v%s listening %s (device_id=%s)", Version, *listen, mf.DeviceID) + + // Graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutdown signal received, draining...") + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + log.Println("bye") +} + +func defaultManifestPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "device_agent", "manifest.yaml") +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]any{ + "ok": false, + "error": code, + "msg": msg, + }) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(ww, r) + dur := time.Since(start).Milliseconds() + log.Printf("%s %s %d %dms from=%s", r.Method, r.URL.Path, ww.status, dur, clientIP(r)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + if strings.HasPrefix(host, "::") { + return host + } + return host +} diff --git a/manifest.go b/manifest.go new file mode 100644 index 0000000..1bd4384 --- /dev/null +++ b/manifest.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Capability struct { + Name string `yaml:"name"` + BinariesAllowed []string `yaml:"binaries_allowed,omitempty"` + PathsAllowed []string `yaml:"paths_allowed,omitempty"` + RequiresApproval bool `yaml:"requires_approval"` + // shell.eval specific fields + Blocklist []string `yaml:"blocklist,omitempty"` // regex patterns hardcoded + extendible + AutoApprove []string `yaml:"auto_approve,omitempty"` // regex patterns pre-approved + ShellMode string `yaml:"shell_mode,omitempty"` // "bash"|"powershell"|"auto" (default auto) + MaxOutputBytes int `yaml:"max_output_bytes,omitempty"` // default 1MB + TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` // default 60 + // browser.* specific fields (CDP) + ChromeCDPHost string `yaml:"chrome_cdp_host,omitempty"` // default 127.0.0.1 + ChromeCDPPort int `yaml:"chrome_cdp_port,omitempty"` // default 9223 + ScreenshotDir string `yaml:"screenshot_dir,omitempty"` // dest dir for screenshots (browser.screenshot) +} + +type Manifest struct { + DeviceID string `yaml:"device_id"` + Operator string `yaml:"operator"` + Capabilities []Capability `yaml:"capabilities"` +} + +func LoadManifest(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read manifest %s: %w", path, err) + } + var mf Manifest + if err := yaml.Unmarshal(data, &mf); err != nil { + return nil, fmt.Errorf("parse manifest yaml: %w", err) + } + if mf.DeviceID == "" { + return nil, fmt.Errorf("manifest missing device_id") + } + if len(mf.Capabilities) == 0 { + return nil, fmt.Errorf("manifest has no capabilities") + } + return &mf, nil +} + +// CapabilityByName devuelve el bloque del manifest para name, o nil si no esta. +func (m *Manifest) CapabilityByName(name string) *Capability { + for i := range m.Capabilities { + if m.Capabilities[i].Name == name { + return &m.Capabilities[i] + } + } + return nil +} diff --git a/smoke_capabilities.sh b/smoke_capabilities.sh new file mode 100755 index 0000000..6316ede --- /dev/null +++ b/smoke_capabilities.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Smoke iterativo end-to-end de las 12 capabilities nuevas (+ 2 base = 14 total). +# Asume device_agent corriendo en 10.42.0.10:7474 con manifest enriched. + +set -uo pipefail + +URL=http://10.42.0.10:7474/capability +AUDIT_DB=/tmp/device_audit.db +PASS=0 +FAIL=0 +FAIL_NAMES=() + +audit_count_before=$(sqlite3 "$AUDIT_DB" "SELECT COUNT(*) FROM audit_log;" 2>/dev/null || echo 0) +echo "[smoke] audit_log rows BEFORE: $audit_count_before" +echo + +call() { + local name="$1" + local payload="$2" + local rid="smoke-$(date +%s%N)-$RANDOM" + local body + body=$(echo "$payload" | sed "s/__RID__/$rid/") + local resp + resp=$(curl -sS -X POST "$URL" -H "Content-Type: application/json" -d "$body") + local ok + ok=$(echo "$resp" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("ok",False))' 2>/dev/null || echo "False") + if [[ "$ok" == "True" ]]; then + echo "[OK] $name" + PASS=$((PASS+1)) + else + echo "[FAIL] $name -> $resp" + FAIL=$((FAIL+1)) + FAIL_NAMES+=("$name") + fi +} + +# 1. shell.exec (baseline) +call "shell.exec" '{"request_id":"__RID__","capability":"shell.exec","args":["echo","smoke-test"]}' + +# 2. shell.eval (baseline) +call "shell.eval" '{"request_id":"__RID__","capability":"shell.eval","args":{"cmd":"echo hello-eval"}}' + +# 3. fs.read +echo "smoke-payload" > /tmp/smoke_test_file.txt +call "fs.read" '{"request_id":"__RID__","capability":"fs.read","args":{"path":"/tmp/smoke_test_file.txt"}}' + +# 4. fs.write +B64=$(echo -n "smoke-write" | base64) +call "fs.write" "{\"request_id\":\"__RID__\",\"capability\":\"fs.write\",\"args\":{\"path\":\"/tmp/smoke_written.txt\",\"content_b64\":\"$B64\"}}" + +# 5. fs.list +call "fs.list" '{"request_id":"__RID__","capability":"fs.list","args":{"dir":"/tmp"}}' + +# 6. fs.stat +call "fs.stat" '{"request_id":"__RID__","capability":"fs.stat","args":{"path":"/tmp/smoke_test_file.txt"}}' + +# 7. git.clone (local repo for offline-safe test) +rm -rf /tmp/smoke_git_src /tmp/smoke_git_dst +mkdir -p /tmp/smoke_git_src && cd /tmp/smoke_git_src && git init -q -b master && git config user.email t@x && git config user.name t && echo seed > f.txt && git add . && git commit -q -m init +call "git.clone" '{"request_id":"__RID__","capability":"git.clone","args":{"url":"/tmp/smoke_git_src","dest":"/tmp/smoke_git_dst"}}' + +# 8. git.commit (on cloned repo, add new file) +cd /tmp/smoke_git_dst && git config user.email t@x && git config user.name t && echo new > new.txt +call "git.commit" '{"request_id":"__RID__","capability":"git.commit","args":{"repo":"/tmp/smoke_git_dst","message":"smoke commit","files":["new.txt"]}}' + +# 9. git.push: setup bare repo as remote so push succeeds end-to-end +rm -rf /tmp/smoke_git_remote.git +mkdir -p /tmp/smoke_git_remote.git && (cd /tmp/smoke_git_remote.git && git init -q --bare) +(cd /tmp/smoke_git_dst && git remote remove origin 2>/dev/null; git remote add origin /tmp/smoke_git_remote.git) +call "git.push" '{"request_id":"__RID__","capability":"git.push","args":{"repo":"/tmp/smoke_git_dst","remote":"origin","branch":"master"}}' + +# 10. pkg.search (use real apt-cache since on ubuntu) +call "pkg.search" '{"request_id":"__RID__","capability":"pkg.search","args":{"query":"vim"}}' + +# 11. proc.list +call "proc.list" '{"request_id":"__RID__","capability":"proc.list","args":{}}' + +# 12. docker.container.list +call "docker.container.list" '{"request_id":"__RID__","capability":"docker.container.list","args":{"all":true}}' + +# 13. docker.container.exec (needs a running container) +CID=$(docker ps --format '{{.Names}}' 2>/dev/null | head -n1) +if [[ -n "$CID" ]]; then + call "docker.container.exec" "{\"request_id\":\"__RID__\",\"capability\":\"docker.container.exec\",\"args\":{\"container\":\"$CID\",\"argv\":[\"ls\",\"/\"]}}" +else + echo "[skip docker.container.exec: no running containers]" +fi + +# 14. docker.container.logs +if [[ -n "$CID" ]]; then + call "docker.container.logs" "{\"request_id\":\"__RID__\",\"capability\":\"docker.container.logs\",\"args\":{\"container\":\"$CID\",\"tail\":10}}" +else + echo "[skip docker.container.logs: no running containers]" +fi + +echo +echo "===== SMOKE SUMMARY =====" +echo "PASS: $PASS" +echo "FAIL: $FAIL" +if [[ $FAIL -gt 0 ]]; then + echo "Failed: ${FAIL_NAMES[*]}" +fi + +audit_count_after=$(sqlite3 "$AUDIT_DB" "SELECT COUNT(*) FROM audit_log;" 2>/dev/null || echo 0) +echo "[smoke] audit_log rows AFTER: $audit_count_after" +echo "[smoke] delta: $((audit_count_after - audit_count_before))"