Skip to content

hulak env exec — vault-aware command execution with project + user vault resolution #171

@xaaha

Description

@xaaha

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 / --project flags)

Per-vault operations

Exec

Dependency graph

#190 (foundation)
├── #191 list/keys
├── #192 set/delete ──┬── #193 get/edit
│                      └── #194 migrate
├── #195 recipients
├── #196 rotate-key
├── #197 backup/restore
└── #198 exec POSIX ─── #199 exec Windows + edges

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:

  1. Hard-code secrets in shell rc files (defeats the vault)
  2. export API_KEY=$(hulak env get API_KEY --env prod) (works but leaks via shell history and ps)
  3. 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.

Vault resolution model

Layout

~/.config/hulak/
├── identity.txt           # private key (already exists)
└── store.age              # NEW — user-level vault (optional)

<any-repo>/.hulak/
└── store.age              # project-level vault (current behavior)

Discovery (walk-up)

Mirrors git rev-parse --show-toplevel:

  1. Start at cwd
  2. Walk parent directories looking for .hulak/store.age
  3. Stop at the first match, or at filesystem root

This means hulak env exec -- ... from any subdirectory of a project finds that project's vault, just like git status works from src/components/.

Resolution + merge

For every read operation, hulak loads:

  1. User vault (~/.config/hulak/store.age) if it exists
  2. Project vault (.hulak/store.age from walk-up) if found

Keys merge with project overriding user. Same env-name semantics as today (global is base; --env <name> overlays). Final precedence:

project[<env>] > project[global] > user[<env>] > user[global]

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.

HULAK_VAULT_PATH escape hatch

Power users can override discovery entirely:

HULAK_VAULT_PATH=~/work/secrets.age hulak env exec -- ...

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 used
cd ~
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 project
cd ~/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
  • Exit code propagates: hulak env exec -- false exits with code 1
  • 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 without store.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
pkg/userFlags/subcommands.go Wire exec subcommand
pkg/utils/ HULAK_VAULT_PATH env-var helper (mirror HULAK_MASTER_KEY pattern)

Implementation sketch

Discovery

// pkg/vault/store.go

// DiscoverProjectStore walks up from cwd looking for .hulak/store.age.
// Returns the path if found, or empty string if no project vault exists in
// any ancestor directory.
func DiscoverProjectStore() (string, error) {
    cwd, err := os.Getwd()
    if err != nil { return "", err }

    dir := cwd
    for {
        candidate := filepath.Join(dir, ".hulak", "store.age")
        if _, err := os.Stat(candidate); err == nil {
            return candidate, nil
        }
        parent := filepath.Dir(dir)
        if parent == dir { return "", nil } // hit root
        dir = parent
    }
}

// ResolveStores returns the ordered list of vault paths to load,
// honoring HULAK_VAULT_PATH override.
func ResolveStores() ([]string, error) {
    if override := os.Getenv("HULAK_VAULT_PATH"); override != "" {
        return []string{override}, nil
    }

    var stores []string
    if userPath, err := UserStorePath(); err == nil && fileExists(userPath) {
        stores = append(stores, userPath)
    }
    if projectPath, err := DiscoverProjectStore(); err == nil && projectPath != "" {
        stores = append(stores, projectPath)
    }
    return stores, nil
}

BuildEnviron

// pkg/vault/exec.go

// BuildEnviron returns os.Environ()-style "KEY=VALUE" entries
// merging the parent process env with the resolved vault chain.
// Project vault values override user vault values; both override
// nothing in the parent env unless the same key is set.
func BuildEnviron(envName string) ([]string, error) {
    secrets, err := loadMergedSecrets(envName)
    if err != nil {
        return nil, err
    }

    env := os.Environ()
    seen := make(map[string]int, len(env))
    for i, kv := range env {
        if eq := strings.IndexByte(kv, '='); eq > 0 {
            seen[kv[:eq]] = i
        }
    }

    for k, v := range secrets {
        entry := fmt.Sprintf("%s=%v", k, v)
        if i, ok := seen[k]; ok {
            env[i] = entry
        } else {
            env = append(env, entry)
        }
    }
    return env, nil
}

runExec

// pkg/userFlags/env_exec.go (POSIX path)

func runExec(args []string, envName string, dryRun bool) error {
    if len(args) == 0 && !dryRun {
        return fmt.Errorf("no command provided after '--'")
    }

    childEnv, err := vault.BuildEnviron(envName)
    if err != nil {
        return err
    }

    if dryRun {
        printDryRun(childEnv)
        return nil
    }

    bin, err := exec.LookPath(args[0])
    if err != nil {
        return fmt.Errorf("command not found: %s", args[0])
    }

    return syscall.Exec(bin, args, childEnv) // POSIX only
}

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
  • exec --env prod -- false exits with code 1
  • exec --env prod -- true exits with code 0
  • Without --: error "no command provided"
  • Empty --env arg → error
  • Unknown env name → error before exec
  • --dry-run lists keys with masked values + source vaults, doesn't spawn
  • Parent env is preserved on keys not in any vault: FOO=bar hulak env exec -- printenv FOO prints bar
  • Vault overrides parent on conflict
  • Ctrl+C during a long-running child kills the child cleanly (manual / integration test)
  • Stdin pipes through: echo hi | hulak env exec -- cat prints hi

Security

  • No temp files. Secrets only exist in the child process's env table.
  • Don't log the command line. It may contain shell-expanded secrets.
  • --dry-run masks all values with fixed-width •••••••• regardless of true length. Don't preview lengths.
  • /proc/<pid>/environ and ps eww: documented as known OS-level visibility risks (same posture as direnv exec).
  • User vault permissions: 0600, mirrored from identity.txt handling. Doctor (vault: hulak doctor health checks for encrypted store #169) flags loose perms.
  • 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.

Depends on

Blocks

Out of scope

  • A daemon mode that watches store.age for changes and re-exports
  • Shell completion of vault keys
  • Per-key allow/deny lists (exec --only API_KEY -- ...)
  • Inheriting from a monorepo parent vault (only nearest .hulak/ is used)
  • Multi-user-vault setups (one user vault per OS user is enough)

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestepic: encryptionIssues that belong to the encryption epic

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions