---
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.
