You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This ticket is the design doc; implementation is split across 10 sub-issues, each ≤1 day and independently reviewable. Order reflects dependencies — work top-down.
Total estimated effort: ~7 focused days. Each sub-issue is a single PR.
Summary
hulak env exec runs an arbitrary command with secrets from the encrypted store injected as environment variables, plus a two-tier vault resolution model (user-level + project-level) so the secrets are reachable from anywhere — not just inside a hulak project directory.
Together these turn hulak from "encrypted env vars for an API client" into a real project-scoped secret manager that can pipe into any tool (npm, prisma, psql, docker, ad-hoc shell), with a global fallback for personal tokens that span all of a user's repos.
Why
The exec piece
Hulak's secrets are great for hulak's own request files. But teams use those same secrets in dozens of other places: npm start, ./deploy.sh, python manage.py, ad-hoc psql, etc. Today the user has three bad options:
Hard-code secrets in shell rc files (defeats the vault)
export API_KEY=$(hulak env get API_KEY --env prod) (works but leaks via shell history and ps)
Maintain a parallel .env file (defeats the vault again)
hulak env exec solves all three by injecting the env into a child process directly, never touching the parent shell. This pattern is a flagship feature of every popular secrets manager: direnv, sops-exec, doppler run, infisical run, chamber exec, aws-vault exec, op run (1Password). Users will expect it.
The vault-resolution piece
Today the vault lives at .hulak/store.age inside a project directory. That works for project-scoped secrets, but creates a real friction point for users with 10+ repos:
Truly shared tokens (GITHUB_TOKEN, OPENAI_API_KEY, AWS_ACCESS_KEY_ID) get duplicated into every project vault, or fall back to shell rc files
Calling hulak env exec from outside a hulak project fails entirely
The "encryption tool" framing breaks down — the vault feels stuck inside the project
A two-tier model (user-level + project-level, with project overriding user) — borrowed from git config --global/--local and mise — fixes this without forcing users to restructure their existing repos.
If only one vault exists, that's the only one loaded. If neither exists and no HULAK_VAULT_PATH is set, commands that need secrets exit with a clear "no vault found" message.
When set, this single vault is used as-is. No walk-up, no user-vault merge. Useful for:
Multiple distinct work vaults that shouldn't merge
CI environments without a writable home dir
Trying out a vault before committing to it
Write destination (--user flag)
Writes need an explicit destination. Default rules:
Inside a project (walk-up found .hulak/): writes go to project vault
Outside any project: writes go to user vault
--user flag forces user vault regardless of cwd
Applies to all CRUD subcommands: set, delete, edit, migrate, etc.
hulak env set OPENAI_API_KEY sk-... --user # user vault
hulak env set DATABASE_URL postgres://... --env prod # project vault (cwd inside repo)
hulak env list --user # show user vault contents only
hulak env list # show merged view (default)
hulak env list --project # show project vault contents only
Commands
hulak env exec
# Run any command with env from the vault
hulak env exec --env prod -- npm start
hulak env exec --env staging -- ./deploy.sh
hulak env exec --env prod -- bash -c 'curl -H "Authorization: Bearer $API_KEY" $BASE_URL/health'# Default env is global if --env is omitted
hulak env exec -- python manage.py migrate
# From outside a project, user vault is usedcd~
hulak env exec -- gh pr list # uses GITHUB_TOKEN from user vault# Spawn an interactive shell with the env loaded
hulak env exec --env prod -- $SHELL# Show what would be exported without running anything
hulak env exec --env prod --dry-run
# Would set:# BASE_URL# API_KEY (••••••••)# DB_PASSWORD (••••••••)# Sources: user (~/.config/hulak/store.age), project (.hulak/store.age)
CRUD with --user flag
# Write to user vault (truly global secrets)
hulak env set GITHUB_TOKEN ghp_xxx --user
hulak env set OPENAI_API_KEY sk-... --user
# Default writes to project vault when inside a projectcd~/repos/api-service
hulak env set DATABASE_URL postgres://... --env prod
# List with explicit source
hulak env list --user # only user vault keys
hulak env list --project # only project vault keys
hulak env list # merged view (default), with origin annotation# global:# GITHUB_TOKEN (user)# DATABASE_URL (project, overrides user)# API_KEY (project)
Behavior
env exec
--env <name> defaults to global (matches other env subcommands)
-- separator is required to disambiguate hulak flags from the child command's flags
Child process inherits stdin/stdout/stderr (full pipe transparency — no buffering)
Child process inherits the parent's existing env, then the merged vault env is overlaid on top
Signals propagate: Ctrl+C to hulak forwards SIGINT to the child
--dry-run prints the keys that would be set (with masked values) and the source vaults, then exits
Vault discovery
Walk-up stops at filesystem root or the first .hulak/store.age found
A .hulak/ directory withoutstore.age is treated as not-a-vault (no error)
Discovery is read-only — no writes happen during walk-up
Symlinks in the path are followed (matches git's behavior)
What it does NOT do
Does not write a temp .env file. Secrets only exist in the child process's env table — same security posture as eval $(env-export-cmd) but without leaking to the parent shell.
Does not log the command. Users may have expanded secrets via shell substitution before exec sees them — we can't prevent that, but we don't make it worse.
Does not modify the parent shell. Secrets are scoped to the child only.
Does not auto-create a user vault. If ~/.config/hulak/store.age doesn't exist, the first hulak env set --user creates it (consistent with how project vaults work today).
Does not merge across multiple project vaults. Only the nearest .hulak/ from walk-up is used. No "inherit from parent monorepo's vault" feature — out of scope.
Comparison to existing tools
Tool
Equivalent command
direnv
direnv exec . <cmd> (dir-scoped, not vault)
sops
sops exec-env <file> '<cmd>'
doppler
doppler run -- <cmd>
1Password CLI
op run -- <cmd>
chamber
chamber exec <service> -- <cmd>
aws-vault
aws-vault exec <profile> -- <cmd>
The -- separator + --env flag pattern is the de-facto standard. Stick to it.
Files to Create/Change
File
Change
pkg/vault/store.go
Add UserStorePath(), DiscoverProjectStore() (walk-up), ResolveStores() (ordered list of stores to read from)
pkg/vault/exec.go
NEW — BuildEnviron(envName) ([]string, error) reading the merged result
pkg/vault/merge.go
NEW (or fold into store.go) — merge logic respecting project > user precedence
pkg/userFlags/env_exec.go
NEW — exec subcommand handler with POSIX syscall.Exec + Windows exec.Command fallback
pkg/userFlags/env_crud.go
Add --user / --project flags; route writes to correct destination
pkg/userFlags/env_list.go
Add --user / --project filters and origin annotation in default view
syscall.Exec (vs exec.Command) has the nice property that hulak literally becomes the child process — no extra process in ps output, signals route correctly with no forwarding logic, exit code is the child's automatically. Windows uses exec.Command + manual signal forwarding since syscall.Exec is POSIX-only.
Tests
Vault resolution
Walk-up finds .hulak/store.age in cwd, parent, grandparent
Walk-up stops at filesystem root with empty result
HULAK_VAULT_PATH overrides walk-up entirely
User vault loads when no project vault is found
Both vaults merge with project keys overriding user keys
--user flag writes to user vault even when inside a project
--project flag fails cleanly if no project vault is found
hulak env list annotates each key's source vault
Empty user vault + empty project vault: clean "no vault" error
Exec
exec --env prod -- printenv API_KEY outputs the merged value
exec -- printenv API_KEY (no flag) outputs the global value
syscall.Exec doesn't run defers in the parent, so anything we open before exec needs explicit cleanup. Currently nothing — BuildEnviron returns by value.
Edge cases
Windows: syscall.Exec doesn't exist. Use exec.Command + propagate exit code via os.Exit(cmd.ProcessState.ExitCode()) and install a SIGINT/SIGTERM handler that forwards to the child PID.
Vault keys with non-POSIX env-var names (e.g. MY-VAR, names starting with a digit): skip with a stderr warning per key. Don't pass them through silently.
Empty value in vault: pass through as KEY=. Distinct from "key not in vault."
Builtin-only commands (cd, source, set): exec.LookPath will fail; surface "command not found, did you mean hulak env exec -- bash -c '<builtin>'?"
Stdin closed before child starts (echo hi | hulak env exec -- cat): pipe must be inherited, not buffered through hulak.
Container patterns: brief docker run example since users will ask. The pattern is hulak env exec -- docker run -e API_KEY -e DB_URL <image> (env-var name only, no value), since docker reads names from the parent env.
Work breakdown (v0.3 close-out)
This ticket is the design doc; implementation is split across 10 sub-issues, each ≤1 day and independently reviewable. Order reflects dependencies — work top-down.
Foundation
CRUD plumbing (each adds
--user/--projectflags)env list/env keys(merged view + filters + origin annotation)env set/env delete(write routing + key-conflict refusal + auto-create user vault)env get/env edit(source flags)env migrate(destination flag)Per-vault operations
env recipients(per-vaultrecipients.txt)env rotate-key(per-vault scoping)env backup/env restore(source-vault marker)Exec
env execPOSIX core (syscall.Exec+--dry-run+ masking)env execWindows fallback + edge casesDependency graph
Total estimated effort: ~7 focused days. Each sub-issue is a single PR.
Summary
hulak env execruns an arbitrary command with secrets from the encrypted store injected as environment variables, plus a two-tier vault resolution model (user-level + project-level) so the secrets are reachable from anywhere — not just inside a hulak project directory.Together these turn hulak from "encrypted env vars for an API client" into a real project-scoped secret manager that can pipe into any tool (npm, prisma, psql, docker, ad-hoc shell), with a global fallback for personal tokens that span all of a user's repos.
Why
The exec piece
Hulak's secrets are great for hulak's own request files. But teams use those same secrets in dozens of other places:
npm start,./deploy.sh,python manage.py, ad-hoc psql, etc. Today the user has three bad options:export API_KEY=$(hulak env get API_KEY --env prod)(works but leaks via shell history andps).envfile (defeats the vault again)hulak env execsolves all three by injecting the env into a child process directly, never touching the parent shell. This pattern is a flagship feature of every popular secrets manager: direnv, sops-exec, doppler run, infisical run, chamber exec, aws-vault exec, op run (1Password). Users will expect it.The vault-resolution piece
Today the vault lives at
.hulak/store.ageinside a project directory. That works for project-scoped secrets, but creates a real friction point for users with 10+ repos:GITHUB_TOKEN,OPENAI_API_KEY,AWS_ACCESS_KEY_ID) get duplicated into every project vault, or fall back to shell rc fileshulak env execfrom outside a hulak project fails entirelyA two-tier model (user-level + project-level, with project overriding user) — borrowed from
git config --global/--localandmise— fixes this without forcing users to restructure their existing repos.Vault resolution model
Layout
Discovery (walk-up)
Mirrors
git rev-parse --show-toplevel:.hulak/store.ageThis means
hulak env exec -- ...from any subdirectory of a project finds that project's vault, just likegit statusworks fromsrc/components/.Resolution + merge
For every read operation, hulak loads:
~/.config/hulak/store.age) if it exists.hulak/store.agefrom walk-up) if foundKeys merge with project overriding user. Same env-name semantics as today (
globalis base;--env <name>overlays). Final precedence:If only one vault exists, that's the only one loaded. If neither exists and no
HULAK_VAULT_PATHis set, commands that need secrets exit with a clear "no vault found" message.HULAK_VAULT_PATHescape hatchPower users can override discovery entirely:
When set, this single vault is used as-is. No walk-up, no user-vault merge. Useful for:
Write destination (
--userflag)Writes need an explicit destination. Default rules:
.hulak/): writes go to project vault--userflag forces user vault regardless of cwdApplies to all CRUD subcommands:
set,delete,edit,migrate, etc.Commands
hulak env execCRUD with
--userflagBehavior
env exec--env <name>defaults toglobal(matches other env subcommands)--separator is required to disambiguate hulak flags from the child command's flagshulak env exec -- falseexits with code 1Ctrl+Cto hulak forwardsSIGINTto the child--dry-runprints the keys that would be set (with masked values) and the source vaults, then exitsVault discovery
.hulak/store.agefound.hulak/directory withoutstore.ageis treated as not-a-vault (no error)git's behavior)What it does NOT do
.envfile. Secrets only exist in the child process's env table — same security posture aseval $(env-export-cmd)but without leaking to the parent shell.~/.config/hulak/store.agedoesn't exist, the firsthulak env set --usercreates it (consistent with how project vaults work today)..hulak/from walk-up is used. No "inherit from parent monorepo's vault" feature — out of scope.Comparison to existing tools
direnv exec . <cmd>(dir-scoped, not vault)sops exec-env <file> '<cmd>'doppler run -- <cmd>op run -- <cmd>chamber exec <service> -- <cmd>aws-vault exec <profile> -- <cmd>The
--separator +--envflag pattern is the de-facto standard. Stick to it.Files to Create/Change
pkg/vault/store.goUserStorePath(),DiscoverProjectStore()(walk-up),ResolveStores()(ordered list of stores to read from)pkg/vault/exec.goBuildEnviron(envName) ([]string, error)reading the merged resultpkg/vault/merge.goproject > userprecedencepkg/userFlags/env_exec.goexecsubcommand handler with POSIXsyscall.Exec+ Windowsexec.Commandfallbackpkg/userFlags/env_crud.go--user/--projectflags; route writes to correct destinationpkg/userFlags/env_list.go--user/--projectfilters and origin annotation in default viewpkg/userFlags/subcommands.goexecsubcommandpkg/utils/HULAK_VAULT_PATHenv-var helper (mirrorHULAK_MASTER_KEYpattern)Implementation sketch
Discovery
BuildEnvironrunExecsyscall.Exec(vsexec.Command) has the nice property that hulak literally becomes the child process — no extra process inpsoutput, signals route correctly with no forwarding logic, exit code is the child's automatically. Windows usesexec.Command+ manual signal forwarding sincesyscall.Execis POSIX-only.Tests
Vault resolution
.hulak/store.agein cwd, parent, grandparentHULAK_VAULT_PATHoverrides walk-up entirely--userflag writes to user vault even when inside a project--projectflag fails cleanly if no project vault is foundhulak env listannotates each key's source vaultExec
exec --env prod -- printenv API_KEYoutputs the merged valueexec -- printenv API_KEY(no flag) outputs the global valueexec --env prod -- falseexits with code 1exec --env prod -- trueexits with code 0--: error "no command provided"--envarg → error--dry-runlists keys with masked values + source vaults, doesn't spawnFOO=bar hulak env exec -- printenv FOOprintsbarCtrl+Cduring a long-running child kills the child cleanly (manual / integration test)echo hi | hulak env exec -- catprintshiSecurity
--dry-runmasks all values with fixed-width••••••••regardless of true length. Don't preview lengths./proc/<pid>/environandps eww: documented as known OS-level visibility risks (same posture asdirenv exec).0600, mirrored fromidentity.txthandling. Doctor (vault: hulak doctor health checks for encrypted store #169) flags loose perms.syscall.Execdoesn't run defers in the parent, so anything we open before exec needs explicit cleanup. Currently nothing —BuildEnvironreturns by value.Edge cases
syscall.Execdoesn't exist. Useexec.Command+ propagate exit code viaos.Exit(cmd.ProcessState.ExitCode())and install a SIGINT/SIGTERM handler that forwards to the child PID.MY-VAR, names starting with a digit): skip with a stderr warning per key. Don't pass them through silently.KEY=. Distinct from "key not in vault."cd,source,set):exec.LookPathwill fail; surface "command not found, did you meanhulak env exec -- bash -c '<builtin>'?"echo hi | hulak env exec -- cat): pipe must be inherited, not buffered through hulak.docker runexample since users will ask. The pattern ishulak env exec -- docker run -e API_KEY -e DB_URL <image>(env-var name only, no value), since docker reads names from the parent env.Depends on
hulak envparent command)Blocks
Out of scope
store.agefor changes and re-exportsexec --only API_KEY -- ...).hulak/is used)