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,unlockycompletionsestá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 Rustagey realiza el cifrado y descifrado internamente. - Unix (Linux, BSD, macOS). En Unix
profileusaexecpara sustituir el proceso. En otras plataformas se usaspawn+wait, pero no es el objetivo principal de la versión actual. - Un editor de texto para
sheath edit($SHEATH_EDITOR,$VISUAL,$EDITORovi).
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
lockcifraidentity.txtcon una passphrase y creaidentity.age.unlockdescifraidentity.agey escribeidentity.txtcon permisos0600.
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
| Elemento | Precedencia |
|---|---|
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_HOMEpuede ser absoluto o empezar por~; si es relativo se resuelve contra el directorio actual.-cySHEATH_CONFIGrelativos se resuelven contra el directorio actual.SHEATH_STORE,SHEATH_IDENTITYy las rutas del config se resuelven contraSHEATH_HOME.SHEATH_HOMEy el store deben ser0700.config.toml, la identidad, el fichero de passphrase y los perfiles deben ser0600.- Si
SHEATH_HOMEno existe, solosheath initlo 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=VALUEoexport 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,\ty\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.rsPatrones 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.rsaísla la API deage;fs.rsaísla permisos y escritura atómica;edit.rsaísla el editor externo. - Repository:
Storees la única puerta de entrada al filesystem de perfiles.ProfileNamees un value object que valida rutas y evita traversal. - Strategy: la identidad puede estar en claro o cifrada;
identity.rsresuelve el estado y elige el camino. La ejecución usaexecen Unix yspawn/waitfuera de Unix. - Inyección manual de dependencias:
Config,StoreyEnvPlanse 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
ageX25519 (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_DIRpara el temporal deedit, debe ser un directorio0700; si no, se usa el directorio temporal del sistema. ageautentica el cifrado: un perfil manipulado falla al descifrar.- Las escrituras usan un temporal en el directorio destino y
renameató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::Commandcopia el entorno para el hijo; esa copia no se puede zeroizar desde Rust. En unexecexitoso la imagen del proceso se reemplaza.
En ejecución
execen 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_varni mutaciones globales del entorno. - El programa se resuelve contra el
PATHefectivo. En modo--clean, elPATHpadre 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
SIGKILLdeje un temporal en tmpfs hasta el reinicio; - que root o malware con la sesión del usuario acceda a los secretos;
- que
unlinkborre 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
| Dependencia | Uso |
|---|---|
age | Cifrado X25519 de perfiles y scrypt para identity.age. |
clap | CLI declarativa con derive. |
clap_complete | Generar scripts de completions para shells compatibles. |
home | Resolver ~ y el home del usuario. |
rpassword | Leer passphrases sin eco. |
secrecy | Contenedores de secretos (SecretString). |
serde | Deserializar config.toml. |
shell-words | Parsear $SHEATH_EDITOR/$VISUAL/$EDITOR con argumentos. |
tempfile | Temporales de edit y escritura atómica. |
thiserror | Errores tipados. |
toml | Lectura de configuración. |
toml_edit | Escritura de configuración preservando formato. |
zeroize | Limpieza 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:
listordena perfiles anidados y omite ocultos/temporales.profileinyecta variables, no crea.envy no filtraSHEATH_*.showimprime el contenido descifrado en stdout y completa perfiles anidados.--cleandeja solo las variables del perfil.- En
--clean, elPATHdel padre puede localizar el primer ejecutable sin filtrarse al entorno del hijo. - Un perfil puede definir
SHEATH_HOME. PATHdel 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). initcon 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.
editcon editor correcto, editor que falla y contenido inválido.removecon confirmación, cancelación,--forcey perfil inexistente.completionsgenera scripts de shell sin requerir un store.- Precedencia
-cvsSHEATH_CONFIGvsSHEATH_HOME. SHEATH_HOMErelativo yconfigrelativo al home.
cargo test --all-targetsLimitaciones y trampas conocidas
unlockdejaidentity.txten 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.ageo del recipient y los perfiles. editdepende del editor: si el editor crea swap/backup, esos ficheros pueden contener secretos.- Un
SIGKILLpuede dejar un temporal en$XDG_RUNTIME_DIRo en el directorio temporal del sistema. - La identidad descifrada en
profileno 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 deSHEATH_*,--clean, resolución dePATH, 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) yd28d632(tests).README.mdy.gitignorequedan 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 einitsin passphrase/TTY. - 2026-10-27: se integra
clap_completeconsheath completions <shell>. - 2026-10-27: se añade
sheath removecon confirmación predeterminada negativa y opción--force. - 2026-10-27: se añaden
sheath showy completions dinámicas para perfiles anidados;--cleanusa elPATHpadre solo para resolver el ejecutable inicial si el perfil no define uno. - 2026-10-27: se ordenan los comandos en
--helppor grupos de uso.