Skip to content

feat(secrets): inject bound vault secrets into agent shells as env vars - #276

Merged
JongSikLim merged 1 commit into
mainfrom
feat/noah/bound-secret-env-injection
Jul 29, 2026
Merged

feat(secrets): inject bound vault secrets into agent shells as env vars#276
JongSikLim merged 1 commit into
mainfrom
feat/noah/bound-secret-env-injection

Conversation

@JongSikLim

Copy link
Copy Markdown
Contributor

Summary

Lets a user bind a vault secret to a task so its value is injected into the agent's shell as an environment variable — the model can use it (curl -H "Authorization: Bearer $KEY") but never see the plaintext. This is the "C" option from the design discussion: consumption without exposure.

The value flows Electron-main → child-process env only. It never enters runtimeOptions (ids only), never crosses preload for runtime use, and never lands in prompts, transcripts, BridgeEvents, diagnostics, or logs.

Honest trade-off: this is an automatic-leak guarantee, not a sandbox. A deliberate echo $KEY can still surface a bound value. The Settings copy and AGENTS.md say so explicitly.

What changed

Data model (envVarName on a secret)

  • Optional POSIX-validated envVarName on the stored secret (secret-vault.ts, secrets.ts, IPC schema, settings form). No VAULT_VERSION bump — older files load fine.
  • Reserved-key denylist (PATH, CLAUDE_CONFIG_DIR, CODEX_HOME, STAVE_LOCAL_MCP_TOKEN, ELECTRON_*, …) + global uniqueness among env-var names.

Binding (boundSecretIds, ids only)

  • Plumbed through RuntimeOptionsObjectSchema, ProviderRuntimeOptions, PromptDraftRuntimeOverrides (+ its schema), ResolvedPromptDraftRuntimeState, and buildProviderRuntimeOptions.
  • Persists in the workspace snapshot (survives restart); no SQLite migration.
  • New composer control (SecretBindingControl) to pick which injectable secrets bind to the task. Shows masked preview + env-var name only.

Resolver (main-process only)

  • resolveBoundSecretEnv({ ids }) maps ids → env map, skipping non-injectable / reserved / duplicate names. Not exposed via preload. Logs counts + names only, never values. electron is now lazy-required so importing it into the runtimes doesn't break the bun test import graph.

Claude injection

  • secretEnv spread last into options.env, for the primary turn only — kept out of buildClaudeDiagnostics and every aux/introspection query by construction.

Codex injection + resume-drop fix

  • Injects via per-thread shell_environment_policy.set.<KEY> overrides (same channel as the NVM PATH shim), merged into configOverrides.
  • Folds a name-only fingerprint into the thread key so a changed binding forces a fresh thread/start.
  • Bug fix: thread/resume previously dropped caller configOverrides entirely (MCP isolation + our secrets). Now forwarded on both start and resume.

Testing

  • bun run typecheck clean.
  • bun run check:max-lines-ratchet / check:switch-exhaustiveness / check:reliability-gates pass.
  • New/extended unit tests: tests/secrets.test.ts (+13: env-var normalize, denylist, vault round-trip, legacy load, uniqueness, resolveEnvForIds), tests/codex-app-server-secret-env.test.ts (resume/start override forwarding), tests/prompt-draft-runtime.test.ts (+2: boundSecretIds resolution + equality).
  • Full suite: branch adds zero new failures vs main baseline (remaining failures are pre-existing environmental: nvm binary discovery, fs/timeout).

Not done / follow-ups

  • Manual in-app verification (printenv NAME in the agent shell; confirm value present in shell, absent from transcript) was not run in this environment — the code path is unit-covered but not exercised against a live Electron session.
  • Codex read-only advisor path is intentionally not injected (main-turn parity with Claude), per the plan's v1 recommendation.

🤖 Generated with Claude Code

Let a user bind vault secrets to a task so their values are injected into
the agent's shell as environment variables, without the plaintext ever
entering the model's text channel, the renderer, or logs. The model can run
`curl -H "Authorization: Bearer $KEY"` but cannot read $KEY's value.

- Add optional POSIX `envVarName` to a secret (schema, vault, IPC, settings
  form) with a reserved-key denylist and global uniqueness.
- Plumb `boundSecretIds` (ids only) through runtimeOptions, the prompt-draft
  runtime overrides, persistence, and buildProviderRuntimeOptions.
- Add a main-process-only resolver (resolveBoundSecretEnv) that maps ids to an
  env map, skipping non-injectable / reserved / duplicate names. Never exposed
  via preload; logs counts and names only, never values.
- Claude: spread resolved env into options.env for the primary turn only, so
  secrets stay out of diagnostics and every aux/introspection query.
- Codex: inject via per-thread shell_environment_policy.set.* overrides and
  fold a name-only fingerprint into the thread key so a changed binding forces
  a fresh thread. Fix a latent bug where thread/resume dropped caller config
  overrides (MCP isolation + secrets) entirely.
- Add a composer control to bind secrets, update Settings copy to reflect the
  honest trade-off, and document the invariant in AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@upwind-code-us

upwind-code-us Bot commented Jul 29, 2026

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Proceed with Deployment

0 misconfigurations detected

View full analysis in Upwind Console →

Scan completed in 4s

Scan history (2 scans)
Commit Scanned at New Resolved Net
ee62f25 2026-07-29 08:27 UTC
ee62f25 < 2026-07-29 08:27 UTC

Last scanned: ee62f25 · 2026-07-29 08:27 UTC

@JongSikLim
JongSikLim merged commit 58f756a into main Jul 29, 2026
4 checks passed
@JongSikLim
JongSikLim deleted the feat/noah/bound-secret-env-injection branch July 29, 2026 08:30
@astyfx astyfx mentioned this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants