HASHBANG CORE — SYSADMIN & DEVELOPER

sheath

sheath es una herramienta de línea de comandos escrita en Rust para ejecutar programas con variables de entorno que viven en perfiles cifrados con age. La idea es parecida a env VAR=VALOR programa, pero sin escribir secretos en el historial del shell, sin crear un .env en disco y sin modificar el entorno del shell padre.

Estado: MVP funcional. Los comandos init, list, show, edit, remove, profile, lock, unlock y completions están implementados y cubiertos por tests. Las funcionalidades marcadas como «planificadas» al final todavía no existen.

Requisitos

  • Rust estable reciente (el proyecto usa edición 2024).
  • No hace falta instalar el ejecutable age: Sheath usa la biblioteca Rust age y realiza el cifrado y descifrado internamente.
  • Unix (Linux, BSD, macOS). En Unix profile usa exec para sustituir el proceso. En otras plataformas se usa spawn + wait, pero no es el objetivo principal de la versión actual.
  • Un editor de texto para sheath edit ($SHEATH_EDITOR, $VISUAL, $EDITOR o vi).

Compilar y probar

cargo build --release
cargo test --all-targets
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings

El binario queda en target/release/sheath.

Primeros pasos

Para probar sin tocar la configuración real, usa un SHEATH_HOME aislado:

export SHEATH_HOME="$PWD/playground/sheath-home"
mkdir -m 700 "$SHEATH_HOME"

sheath init --no-passphrase
sheath edit dev
sheath list
sheath profile dev -- env

--no-passphrase deja la identidad en claro en identity.txt y está pensado para CI y pruebas. En uso normal, ejecuta sheath init sin ese flag: la identidad se guarda cifrada con una passphrase en identity.age.

Crear un perfil

sheath edit prod/cliente01

Se abre el editor con una plantilla. El formato es dotenv estricto:

# Perfil: prod/cliente01
FOO=bar
export API_URL=https://example.test
QUOTED="valor con espacios"
LITERAL='sin $expansión'
EMPTY=

Al guardar, el contenido se valida, se cifra con la clave pública del store y se escribe de forma atómica en store/prod/cliente01.age. El texto plano solo existe en el fichero temporal del editor mientras este está abierto.

Ejecutar un comando

sheath profile prod/cliente01 ls -la
sheath profile prod/cliente01 -- ./deploy.sh --flag

El programa hijo recibe las variables del perfil. Por defecto hereda el entorno del shell padre menos todas las variables que empiezan por SHEATH_, y después aplica el perfil (el perfil gana si redefine una variable).

Con --clean no se hereda nada: solo existen las variables del perfil.

sheath profile --clean prod/cliente01 -- /usr/bin/env

Si el perfil define PATH, sheath lo usa para resolver el programa. Con --clean, si el perfil no define PATH, Sheath usa el PATH del proceso padre únicamente para localizar el primer ejecutable. Ese PATH no se pasa al hijo; los comandos que el programa lance por nombre necesitarán un PATH definido en el perfil.

Mostrar un perfil

sheath show prod/cliente01

Descifra y escribe el contenido dotenv del perfil en stdout. Esto imprime los valores secretos en claro; evita redirigir la salida a archivos o compartirla. El nombre del perfil se completa dinámicamente desde el store.

Listar perfiles

sheath list
sheath list prod/

Recorre store/ sin descifrar nada y muestra una ruta por línea, sin el sufijo .age. Un prefijo opcional limita los resultados:

dev
prod/cliente01
prod/cliente02

Se ignoran ficheros ocultos, temporales y todo lo que no termine en .age.

Eliminar un perfil

sheath remove prod/cliente01
sheath remove prod/cliente01 --force

Sin --force, sheath pide confirmación y la respuesta predeterminada es no. Con --force, elimina el perfil sin preguntar. La eliminación es permanente; solo se borra el fichero .age del perfil.

Completar comandos

sheath completions genera la línea de inicialización para el shell indicado. La integración dinámica consulta perfiles y directorios del store al completar list, profile, show, edit y remove:

sheath completions bash >> ~/.bashrc
sheath completions zsh >> ~/.zshrc

Para Fish, ejecuta sheath completions fish y guarda la salida en ~/.config/fish/completions/sheath.fish. Completar nombres solo lee el store; no descifra perfiles.

Bloquear y desbloquear la identidad

sheath lock
sheath unlock
  • lock cifra identity.txt con una passphrase y crea identity.age.
  • unlock descifra identity.age y escribe identity.txt con permisos 0600.

unlock es un compromiso explícito: deja la identidad en claro para no pedir la passphrase en cada ejecución. Si la identidad está bloqueada, sheath profile la descifra solo en memoria y no escribe identity.txt.

Si no hay terminal y no se ha configurado un fichero de passphrase, profile/unlock fallan sin ejecutar nada. SHEATH_NO_PROMPT=1 fuerza ese comportamiento incluso con terminal.

Resolución de rutas y configuración

ElementoPrecedencia
SHEATH_HOME$SHEATH_HOME → ~/.config/sheath
config-c/--config → $SHEATH_CONFIG → $SHEATH_HOME/config.toml
store$SHEATH_STORE → config.store → $SHEATH_HOME/store
identidad$SHEATH_IDENTITY → config.identity → $SHEATH_HOME/identity.txt → $SHEATH_HOME/identity.age
recipient$SHEATH_RECIPIENT → config.recipient → derivado de la identidad
passphrase$SHEATH_IDENTITY_PASSPHRASE_FILE → config.identity_passphrase_file → prompt si hay TTY
editor$SHEATH_EDITOR → config.editor → $VISUAL → $EDITOR → vi

Reglas:

  • SHEATH_HOME puede ser absoluto o empezar por ~; si es relativo se resuelve contra el directorio actual.
  • -c y SHEATH_CONFIG relativos se resuelven contra el directorio actual.
  • SHEATH_STORE, SHEATH_IDENTITY y las rutas del config se resuelven contra SHEATH_HOME.
  • SHEATH_HOME y el store deben ser 0700.
  • config.toml, la identidad, el fichero de passphrase y los perfiles deben ser 0600.
  • Si SHEATH_HOME no existe, solo sheath init lo crea; el resto de comandos fallan con un error claro.

Ejemplo de configuración:

# $SHEATH_HOME/config.toml
store = "store"                 # relativo a SHEATH_HOME
identity = "identity"           # base o fichero .txt/.age
# identity_passphrase_file = "passphrase"
recipient = "age1..."
# editor = "vi"

config.toml se actualiza con toml_edit para conservar comentarios y formato cuando init escribe el recipient.

Formato de perfil

El parser es deliberadamente estricto y no es un intérprete de shell:

  • KEY=VALUE o export KEY=VALUE.
  • Clave válida: [A-Za-z_][A-Za-z0-9_]*.
  • Comentario: # como primer carácter no blanco de la línea.
  • Sin comentarios inline. Un # dentro de un valor forma parte del valor.
  • Sin expansión de variables, sustitución de comandos ni multilinea.
  • Comillas simples: contenido literal, sin escapes.
  • Comillas dobles: se admiten \\, \", \n, \t y \r; cualquier otro escape es error.
  • Valor sin comillas: se recorta el whitespace al inicio y al final.
  • Clave duplicada: gana la última.
  • Línea vacía y valor vacío permitidos.
  • UTF-8 obligatorio; se tolera \r\n.
  • Los errores indican línea y clave, nunca el valor.
  • Límite de tamaño: 1 MiB.

Estructura del proyecto

src/
├── main.rs              # entrada y mapeo de errores a exit codes
├── lib.rs               # módulos y dispatch
├── cli.rs               # definición clap
├── completion.rs        # integración con clap_complete
├── error.rs             # SheathError + Result
├── config.rs            # config.toml + merge con entorno
├── paths.rs             # SHEATH_HOME, store, identidad, precedencia
├── fs.rs                # permisos, escritura atómica, canonicalización
├── store.rs             # listado y resolución de perfiles
├── dotenv.rs            # parser estricto
├── crypto.rs            # age: identidad, recipient, cifrar/descifrar
├── identity.rs          # init, lock, unlock y passphrase
├── env.rs               # entorno seguro, filtrado SHEATH_*, zeroize
├── exec.rs              # resolución de PATH + exec/spawn
└── commands/            # comandos finos que orquestan servicios
    ├── init.rs
    ├── list.rs
    ├── show.rs
    ├── profile.rs
    ├── edit.rs
    ├── remove.rs
    ├── lock.rs
    └── unlock.rs

tests/
├── common/mod.rs        # TestHome, editores falsos, helpers
├── cli_completions.rs
├── cli_config.rs
├── cli_edit.rs
├── cli_identity.rs
├── cli_list.rs
├── cli_profile.rs
├── cli_remove.rs
└── cli_show.rs

Patrones y decisiones de diseño

  • Arquitectura por capas: CLI (cli.rs), configuración (config.rs, paths.rs), dominio (store.rs, identity.rs, dotenv.rs), infraestructura (fs.rs, crypto.rs, exec.rs) y comandos finos (commands/).
  • Adapter: crypto.rs aísla la API de age; fs.rs aísla permisos y escritura atómica; edit.rs aísla el editor externo.
  • Repository: Store es la única puerta de entrada al filesystem de perfiles. ProfileName es un value object que valida rutas y evita traversal.
  • Strategy: la identidad puede estar en claro o cifrada; identity.rs resuelve el estado y elige el camino. La ejecución usa exec en Unix y spawn/wait fuera de Unix.
  • Inyección manual de dependencias: Config, Store y EnvPlan se pasan explícitamente a los comandos, sin estado global ni singletons.
  • Zeroize: los valores de perfil, la passphrase y los buffers descifrados viven en contenedores que se limpian al dropear.

No se usan unwrap() ni expect() en rutas de producción.

Seguridad

En reposo

  • Perfiles cifrados con age X25519 (ChaCha20-Poly1305 + X25519).
  • Identidad bloqueada cifrada con passphrase mediante scrypt.
  • Store y SHEATH_HOME: 0700.
  • Config, identidad, passphrase, perfiles y temporales de edición: 0600.
  • Si se usa $XDG_RUNTIME_DIR para el temporal de edit, debe ser un directorio 0700; si no, se usa el directorio temporal del sistema.
  • age autentica el cifrado: un perfil manipulado falla al descifrar.
  • Las escrituras usan un temporal en el directorio destino y rename atómico, sin seguir symlinks al escribir.

En memoria

  • El perfil descifrado vive en un Zeroizing<Vec<u8>>.
  • Las variables parseadas se guardan en SecretEnv, que zeroiza sus valores.
  • La passphrase y la identidad descifrada se zeroizan.
  • Los valores nunca se incluyen en Debug, errores ni logs.
  • std::process::Command copia el entorno para el hijo; esa copia no se puede zeroizar desde Rust. En un exec exitoso la imagen del proceso se reemplaza.

En ejecución

  • exec en Unix evita dejar un proceso padre con secretos en memoria.
  • El hijo recibe las variables por entorno, que es el mecanismo pedido.
  • Se eliminan del entorno heredado todas las variables SHEATH_* antes de aplicar el perfil. El perfil puede volver a definirlas si lo necesita.
  • No se usan std::env::set_var ni mutaciones globales del entorno.
  • El programa se resuelve contra el PATH efectivo. En modo --clean, el PATH padre solo se usa para localizar el ejecutable inicial cuando el perfil no define uno; nunca se hereda al hijo.

Límites del modelo

sheath no puede evitar:

  • que el programa hijo lea sus propias variables;
  • que otro proceso del mismo usuario lea /proc/<pid>/environ;
  • que el hijo haga logging de secretos;
  • que el editor cree swap/backup con el texto plano temporal;
  • que un SIGKILL deje un temporal en tmpfs hasta el reinicio;
  • que root o malware con la sesión del usuario acceda a los secretos;
  • que unlink borre físicamente datos en SSD/COW.

El cifrado de disco completo y un editor sin swap son responsabilidad del usuario. La seguridad real viene del cifrado y de los permisos; SHEATH_HOME solo aísla config/store y evita contaminar el entorno del shell.

Dependencias

DependenciaUso
ageCifrado X25519 de perfiles y scrypt para identity.age.
clapCLI declarativa con derive.
clap_completeGenerar scripts de completions para shells compatibles.
homeResolver ~ y el home del usuario.
rpasswordLeer passphrases sin eco.
secrecyContenedores de secretos (SecretString).
serdeDeserializar config.toml.
shell-wordsParsear $SHEATH_EDITOR/$VISUAL/$EDITOR con argumentos.
tempfileTemporales de edit y escritura atómica.
thiserrorErrores tipados.
tomlLectura de configuración.
toml_editEscritura de configuración preservando formato.
zeroizeLimpieza de buffers con secretos.

Dev: assert_cmd, predicates y tempfile para tests de integración.

age es una dependencia de biblioteca compilada junto con Sheath; no se requiere instalar el comando age en el sistema.

No se añaden tokio, anyhow, log, tracing, keyring ni agentes.

Modelo de almacenamiento

No hay índice ni base de datos: el store es el filesystem.

$SHEATH_HOME/                  # default: ~/.config/sheath
├── config.toml                # opcional
├── store/                     # default: $SHEATH_HOME/store
│   ├── dev.age                # perfil "dev"
│   └── prod/
│       ├── cliente01.age      # perfil "prod/cliente01"
│       └── cliente02.age
├── identity.txt               # identidad en claro, 0600, si está desbloqueada
└── identity.age               # identidad cifrada con passphrase, si está bloqueada

Cada *.age dentro de store/ es un perfil. El nombre es la ruta relativa al store sin el sufijo .age. Los subdirectorios son namespaces. Si el store se versiona, los nombres de perfil siguen siendo metadatos visibles.

Tests

Los tests unitarios cubren el parser dotenv, la validación de nombres de perfil, la resolución de rutas, los permisos y la escritura atómica, el filtrado de SHEATH_* y el cifrado/descifrado básico.

Los tests de integración usan un SHEATH_HOME temporal y editores falsos:

  • list ordena perfiles anidados y omite ocultos/temporales.
  • profile inyecta variables, no crea .env y no filtra SHEATH_*.
  • show imprime el contenido descifrado en stdout y completa perfiles anidados.
  • --clean deja solo las variables del perfil.
  • En --clean, el PATH del padre puede localizar el primer ejecutable sin filtrarse al entorno del hijo.
  • Un perfil puede definir SHEATH_HOME.
  • PATH del perfil se usa para resolver el programa.
  • Perfil inexistente o cifrado corrupto: falla y no ejecuta.
  • Se propaga el código de salida del hijo (42).
  • init con y sin passphrase; lock/unlock; prompt ausente.
  • Permisos incorrectos en identidad, store, config, perfil y passphrase.
  • Path traversal, componentes ocultos y symlinks fuera del store.
  • edit con editor correcto, editor que falla y contenido inválido.
  • remove con confirmación, cancelación, --force y perfil inexistente.
  • completions genera scripts de shell sin requerir un store.
  • Precedencia -c vs SHEATH_CONFIG vs SHEATH_HOME.
  • SHEATH_HOME relativo y config relativo al home.
cargo test --all-targets

Limitaciones y trampas conocidas

  • unlock deja identity.txt en claro: es cómodo, pero elimina la protección de la passphrase en reposo.
  • Si se pierde la passphrase, se pierde la identidad. La recuperación es un backup externo de identity.age o del recipient y los perfiles.
  • edit depende del editor: si el editor crea swap/backup, esos ficheros pueden contener secretos.
  • Un SIGKILL puede dejar un temporal en $XDG_RUNTIME_DIR o en el directorio temporal del sistema.
  • La identidad descifrada en profile no se escribe en disco, pero el hijo puede leer sus propias variables.
  • El store no se sincroniza ni se comparte; no hay locking entre procesos.
  • No hay soporte completo de Windows/macOS; el camino principal es Unix.
  • Los nombres de perfil son metadatos visibles.

Planificado

  • sheath export.
  • Salida --json.
  • Múltiples recipients por perfil.
  • Plugins de age (YubiKey, TPM, Secure Enclave).
  • Keyring o agente de caché de identidad.
  • Rotación automática de claves.
  • Cifrado de nombres de perfil.
  • Soporte completo de Windows/macOS y GUI/TUI.

Bitácora

  • 2026-10-27: implementación inicial del MVP: resolución de SHEATH_HOME, config, parser dotenv, store cifrado con age, identidad X25519 con lock/unlock, list, profile, edit, filtrado de SHEATH_*, --clean, resolución de PATH, tests unitarios y de integración. Documentación de seguridad y límites en este README.
  • 2026-10-27: commits b57f6b8 (dependencias), 5f05704 (implementación) y d28d632 (tests). README.md y .gitignore quedan fuera de esos commits porque los gestiona el usuario.
  • 2026-10-27: commit d758e92: modelo de seguridad y límites documentados en el rustdoc de la crate.
  • 2026-10-27: commit d19ed64: nombres de temporales genéricos y listado que ignora entradas con nombres no válidos como perfil.
  • 2026-10-27: commit 8481f51: tests de perfiles con dotenv inválido, limpieza del temporal tras fallo del editor e init sin passphrase/TTY.
  • 2026-10-27: se integra clap_complete con sheath completions <shell>.
  • 2026-10-27: se añade sheath remove con confirmación predeterminada negativa y opción --force.
  • 2026-10-27: se añaden sheath show y completions dinámicas para perfiles anidados; --clean usa el PATH padre solo para resolver el ejecutable inicial si el perfil no define uno.
  • 2026-10-27: se ordenan los comandos en --help por grupos de uso.