--- name: shell_exec_whitelist kind: function lang: go domain: infra version: "1.0.0" purity: impure signature: "func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error)" description: "Ejecuta argv shell con whitelist obligatoria de binarios, SIN shell expansion, timeout context-cancellable con SIGTERM+SIGKILL, stdout/stderr separados con truncate opcional. Para device_agent y otros sandboxes que reciben requests externos." tags: [shell, exec, security, sandbox, device-agent, infra, agents, docker] uses_functions: [] uses_types: [shell_exec_result_go_infra, error_go_core] returns: [shell_exec_result_go_infra] returns_optional: false error_type: "error_go_core" imports: [bytes, context, fmt, os, os/exec, os/user, path/filepath, strconv, strings, syscall, time] tested: true tests: - "echo whitelisted returns stdout" - "binary not in whitelist rejected without spawn" - "timeout kills process and sets TimedOut" - "empty whitelist returns error" - "stdin payload passes to process" - "output exceeding MaxOutputBytes is truncated" - "absolute path in whitelist matches resolved binary" test_file_path: "functions/infra/shell_exec_whitelist_test.go" file_path: "functions/infra/shell_exec_whitelist.go" params: - name: opts.Cmd desc: "argv completo. Cmd[0] es el binario (path absoluto o nombre en PATH). Obligatorio, no puede estar vacío." - name: opts.BinariesAllowed desc: "Whitelist de binarios permitidos. EMPTY = rechaza todo sin spawn (defense in depth). Entry con / se compara con path resolvido; entry bare name se compara con basename del resolvido." - name: opts.Env desc: "Variables de entorno KEY=VAL. Si vacío, se aplica entorno mínimo: PATH=/usr/bin:/bin, HOME, USER, LANG." - name: opts.WorkingDir desc: "Directorio de trabajo. Si vacío usa HOME del usuario actual." - name: opts.TimeoutSeconds desc: "Timeout máximo en segundos. Default 30. Al expirar: SIGTERM → espera 1s → SIGKILL." - name: opts.StdinPayload desc: "Bytes a pasar como stdin al proceso. Nil/vacío = sin stdin." - name: opts.MaxOutputBytes desc: "Límite de bytes por stream (stdout y stderr por separado). Default 1 MB. Activa Truncated=true si se supera." - name: opts.User desc: "Usuario para ejecutar el proceso (nombre o 'uid:gid'). Requiere uid=0. Vacío = usuario actual." output: "ShellExecResult con ExitCode, Stdout, Stderr, Duration (ms), Truncated y TimedOut." --- ## Ejemplo ```go result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{ Cmd: []string{"ls", "-la", "/tmp"}, BinariesAllowed: []string{"ls", "cat", "echo", "id"}, TimeoutSeconds: 10, MaxOutputBytes: 64 * 1024, }) if err != nil { log.Fatalf("exec rejected: %v", err) } fmt.Printf("exit=%d duration=%dms truncated=%v timedOut=%v\n", result.ExitCode, result.Duration, result.Truncated, result.TimedOut) fmt.Println(result.Stdout) ``` Con stdin: ```go result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{ Cmd: []string{"cat"}, BinariesAllowed: []string{"cat"}, StdinPayload: []byte("payload from device_agent"), }) ``` Con path absoluto en whitelist: ```go result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{ Cmd: []string{"/usr/bin/id"}, BinariesAllowed: []string{"/usr/bin/id"}, }) ``` ## Cuando usarla Cuando recibes requests externos (Element Matrix, webhook, agente) que especifican un comando a ejecutar en el host, y necesitas garantizar que solo binarios pre-aprobados corren, sin posibilidad de shell injection. Reemplaza `exec.Command` directa en device_agent o cualquier sandbox que acepte comandos de fuentes no confiables. ## Gotchas - **Empty whitelist rechaza por diseño**: `BinariesAllowed: []string{}` devuelve error inmediato. NUNCA construyas la whitelist dinámicamente desde input externo. - **PATH default mínimo** (`/usr/bin:/bin`): si tu binario está en `/usr/local/bin` u otro directorio, añádelo explícitamente a `Env` o usa el path absoluto en `Cmd[0]` y en `BinariesAllowed`. - **SIGTERM+1s+SIGKILL**: algunos procesos pueden ignorar SIGTERM. SIGKILL es forzoso pero puede dejar recursos abiertos (ficheros, sockets). Diseña el proceso objetivo para manejar SIGTERM limpiamente. - **Truncate aplica POST-exec**: no es streaming. Si el proceso produce 10 GB de output, el buffer crece hasta ese tamaño en RAM antes de truncar. Para procesos con output gigante usa pipes propios o un wrapper de streaming. - **User switch requiere uid=0**: en un entorno sin root (contenedor sin privilegios, proceso normal), pasar `User != ""` siempre devuelve error. Verificar con `os.Getuid() == 0` antes si el campo es opcional. - **`Cmd[0]` es el nombre del binario en PATH** pero la whitelist puede tener paths absolutos o bare names. Precedencia: entry con `/` compara contra el path resolvido por `LookPath`; entry sin `/` compara contra `filepath.Base` del path resolvido. Ambas formas son válidas y pueden coexistir en la misma whitelist.