Skip to content

feat: add vault run for secure secret injection into child processes#167

Merged
nicknisi merged 8 commits into
mainfrom
nicknisi/vault-trusted-access
Jun 2, 2026
Merged

feat: add vault run for secure secret injection into child processes#167
nicknisi merged 8 commits into
mainfrom
nicknisi/vault-trusted-access

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented Jun 1, 2026

Summary

  • Adds workos vault run --secret ENV=vault-name -- <command> that fetches secrets from Vault and injects them as env vars into a child process without exposing values in stdout/stderr
  • vault get and vault get-by-name now return metadata only by default; pass --decrypt to include the decrypted value
  • vault create and vault update accept value from stdin when --value is omitted or set to -, keeping secrets out of shell history
  • Fixes vault create crash ("errors is not iterable") by requiring --org and handling SDK 422 parsing bug in the shared error handler
  • Consolidates error handling: isSdkException exported from api-error-handler, context param for enriched 404 messages, SDK TypeError workaround with .includes() match

vault run

  • --secret ENV_VAR=vault-name (repeatable) maps vault objects to env vars
  • --env selects a specific WorkOS environment (API key + base URL)
  • --dry-run shows metadata without fetching (JSON to stdout, human to stdout)
  • Execution-path JSON metadata goes to stderr so the child owns stdout
  • Parallel secret fetching via Promise.all (fail-fast on first error)
  • Signal forwarding (process.once for SIGINT/SIGTERM), exit code passthrough
  • spawnChild returns Promise<number> for composability and testability

vault get / vault get-by-name

  • Returns metadata only by default (no decrypted value in output)
  • --decrypt flag includes the secret value
  • vault get uses describeObject (never requests decryption); vault get-by-name strips value from response

vault create / vault update

  • --value is now optional; omit or pass - to read from stdin
  • Example: echo "my-secret" | workos vault create --name db-password --org org_123
  • Keeps secrets out of shell history and ps output

vault create fix

  • --org is now required (Vault API requires non-empty key_context)
  • SDK 422 workaround moved to shared createApiErrorHandler with .includes() match

Test plan

  • pnpm test -- 1823 tests pass
  • pnpm typecheck
  • pnpm build
  • workos vault get <id> returns metadata without value
  • workos vault get <id> --decrypt returns metadata with value
  • echo "secret" | workos vault create --name test --org org_123 reads from stdin
  • workos vault create --name test --org org_123 --value secret still works inline
  • workos vault run --dry-run --secret MY_VAR=test-secret -- echo hi prints metadata table
  • workos vault run --secret MY_VAR=test-secret -- sh -c 'echo $MY_VAR' injects the value
  • workos vault create --name x --value y shows "Missing required argument: org"

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 1, 2026

Greptile Summary

Adds vault run for injecting Vault secrets into child processes as env vars, makes vault get/get-by-name metadata-only by default (with --decrypt to expose values), and enables stdin-based secret input for create/update to keep values out of shell history.

  • vault run: parallel secret fetching via Promise.all, signal forwarding with process.once (SIGINT/SIGTERM/SIGBREAK), exit code passthrough, dry-run mode, and JSON metadata emitted to stderr so the child process owns stdout — no secret values appear in any output stream.
  • vault get / vault get-by-name: get uses describeObject (no decryption) when --decrypt is absent; get-by-name calls readObjectByName regardless and strips the value client-side (SDK has no describeObjectByName equivalent).
  • Error handling consolidation: isSdkException exported, context param for enriched 404 messages, SDK 422 TypeError workaround moved to the shared handler using .includes('errors is not iterable').

Confidence Score: 5/5

Safe to merge — no secret values leak to stdout/stderr, exit codes propagate correctly, and signal handling uses one-shot listeners.

The new vault run command is well-guarded: secrets never appear in output, parallel fetching fails fast, signal listeners are once (self-cleaning), and the test suite includes a dedicated security-boundary check. The vault get/create/update changes are additive and backward-compatible at the CLI level. The only design gap is that get-by-name without --decrypt still decrypts server-side (SDK limitation), but the value is never surfaced.

src/commands/vault.ts — runVaultGetByName always calls readObjectByName regardless of the decrypt flag, triggering a server-side decryption even when metadata-only output is requested.

Important Files Changed

Filename Overview
src/commands/vault-run.ts New vault run command: parallel secret fetching, signal forwarding with process.once, child exit-code passthrough, dry-run mode, and no secret values in stdout/stderr. Well-structured.
src/commands/vault.ts Adds --decrypt flag to get/get-by-name, requires --org for create, adds readValueFromStdin. get-by-name without --decrypt still calls readObjectByName (which decrypts server-side), unlike get which uses describeObject.
src/lib/api-error-handler.ts Exports isSdkException, adds optional context param for richer 404 messages, and moves the SDK 422 TypeError workaround here using .includes('errors is not iterable') which correctly covers both old and new V8 message formats.
src/commands/vault-run.spec.ts Comprehensive test suite covering parse validation, parallel fetch, dry-run modes, exit code passthrough, named environment lookup, and a dedicated security-boundary test that verifies secret values never appear in stdout/stderr.
src/bin.ts Wires up new vault run subcommand, threads --decrypt into get/get-by-name, makes --org required for create, and resolves stdin value before calling runVaultCreate/runVaultUpdate.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as vault run (bin.ts)
    participant VaultRun as runVaultRun
    participant Vault as WorkOS Vault API
    participant Child as Child Process

    User->>CLI: "workos vault run --secret ENV=name -- cmd"
    CLI->>VaultRun: "runVaultRun({ secrets, command, env })"
    VaultRun->>VaultRun: parseSecretMappings()
    alt --dry-run
        VaultRun-->>User: print metadata table (stdout)
    else normal execution
        VaultRun->>VaultRun: resolveRunApiKey(env)
        VaultRun->>VaultRun: resolveRunBaseUrl(env)
        par fetchSecrets (Promise.all)
            VaultRun->>Vault: readObjectByName(vaultName1)
            Vault-->>VaultRun: "{ value: secret1 }"
        and
            VaultRun->>Vault: readObjectByName(vaultName2)
            Vault-->>VaultRun: "{ value: secret2 }"
        end
        Note over VaultRun: JSON metadata to stderr (never stdout)
        VaultRun->>Child: "spawn(cmd, args, { env: {...process.env, ENV=secret} })"
        Note over VaultRun,Child: process.once SIGINT/SIGTERM forward to child.kill()
        Child-->>VaultRun: exit(code)
        VaultRun-->>CLI: exitCode
        CLI->>User: process.exit(exitCode)
    end
Loading

Reviews (7): Last reviewed commit: "chore: formatting" | Re-trigger Greptile

Comment thread src/commands/vault-run.ts Outdated
Comment thread src/commands/vault-run.ts Outdated
@nicknisi nicknisi changed the title feat: add vault run for secure secret injection into child processes feat: add vault run for secure secret injection into child processes Jun 1, 2026
Comment thread src/commands/vault.ts Outdated
nicknisi added 5 commits June 1, 2026 19:51
Adds a new `workos vault run --secret ENV=name -- <command>` subcommand
that fetches secrets from WorkOS Vault by name and injects them as
environment variables into a spawned child process. The wrapper itself
never prints secret values, so it can be used safely from AI coding
agents (Codex, Claude Code, Cursor) where the value would otherwise
end up in the model context.

- Sequential fetch with fail-fast (no partial injection on error)
- Error messages reference vault object names only, never values
- `--dry-run` prints the env-var to vault-object mapping (no fetch)
- `--env`, `--org`, and JSON mode supported
- Forwards SIGINT/SIGTERM (and SIGBREAK on Windows) to the child
- Exit code of the child is propagated to the wrapper
…, harden SDK workaround

- Export isSdkException from api-error-handler and add context param for
  enriched 404 messages, eliminating the duplicated type guard in vault-run
- Move handleVaultSdkError into createApiErrorHandler with .includes()
  match to survive V8 message format changes
- Remove --org from vault run (was accepted but never threaded to SDK)
- Mark --org as required in vault create help-json schema
- Replace sequential secret fetching with Promise.all (same fail-fast,
  better latency)
- Use process.once for signal forwarding to prevent listener accumulation
- spawnChild returns Promise<number> instead of calling process.exit
  directly, simplifying tests and making the function composable
- Dry-run JSON goes to stdout (primary output); execution-path metadata
  goes to stderr (child owns stdout)
- Resolve base URL from named environment when --env is set
- Remove dead code: unused imports, unreachable guards, stale test infra
When --env selects an environment without a custom endpoint,
resolveRunBaseUrl fell through to resolveApiBaseUrl() which returns the
active environment's endpoint. This sent the selected env's API key to
the wrong host (e.g. production key to localhost).

Now returns the default WorkOS API base URL when the named env has no
endpoint, instead of leaking the active env's endpoint.
vault create/update: --value is now optional. When omitted or set to -,
the value is read from stdin. This keeps secrets out of shell history
and ps output.

vault get/get-by-name: returns metadata only by default. Pass --decrypt
to include the decrypted secret value. vault get uses describeObject
(never requests decryption); vault get-by-name strips the value from
the response before output.
readValueFromStdin was calling trimEnd() which silently strips trailing
whitespace. The --value flag preserves the exact string, so stdin
should too.
@nicknisi nicknisi force-pushed the nicknisi/vault-trusted-access branch from 2e89333 to c7da7f3 Compare June 2, 2026 00:57
Comment thread src/lib/api-error-handler.ts Outdated
nicknisi added 2 commits June 1, 2026 20:18
Strip only a trailing \r?\n from stdin values so `echo "secret" |`
works as expected without silently altering intentional whitespace.

Narrow the SDK TypeError match from .includes('is not iterable') to
.includes('errors is not iterable') to avoid misclassifying unrelated
TypeErrors like "undefined is not iterable".
@nicknisi nicknisi merged commit c7db4f5 into main Jun 2, 2026
6 checks passed
@nicknisi nicknisi deleted the nicknisi/vault-trusted-access branch June 2, 2026 02:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant