← All posts

tsafe: secrets scoped to the process that needs them

A CI script deploys to production. It runs `aws s3 sync`. You check the env and find AWS_ACCESS_KEY_ID is set — you set it three jobs earlier to pull a build artifact. You forgot it was there. It inherited into this job, and it just pushed to prod using credentials it was never supposed to have. No breach. No alert. A scoping mistake that's invisible until it isn't.

That's the actual failure mode. Not a configuration gap. Not a missing guardrail. A secret in the wrong place at the wrong time, because the environment doesn't scope secrets to the thing that needs them.

What you reach for (and why it doesn't work)

The standard playbook: put secrets in a secret manager, pull them into the CI environment, unset them when done. Here's where each step breaks:

env vars — set once, visible everywhere. Every child process inherits the full environment. If AWS_ACCESS_KEY_ID is set when your deploy step runs, it's set when every subsequent step runs too, including the ones that don't need it.

.env files — loaded globally. The entire file is in scope for everything that runs after it's sourced.

manual unset — you have to remember. You won't. And any subprocess that execs into a new shell gets a fresh env that may pick it back up from the parent process table.

CI env blocks (GitHub Actions `env:`, etc.) — scoped to a job or step at declaration time, but any subprocess inside that step inherits everything. The block is documentation of intent, not runtime enforcement.

Secret managers (Vault, 1Password, AWS Secrets Manager) — solve storage beautifully. Don't solve injection. Once you `export VAR=$(vault read ...)`, you've lost control of scope. The manager's security model ends at the point of export.

The execution boundary

tsafe exec wraps a single command. The secret exists only for the lifetime of that process — injected into the subprocess environment, not the calling shell. It never touches your shell history. It's not in the calling process's env. It ceases to exist when the command exits.

PortableText [components.type] is missing "code"

The key is held by tsafe, injected into the subprocess at launch, and gone when it exits. The calling process never had it.

What this changes

You stop thinking about when to unset secrets and start thinking about which commands actually need them. That's the right mental model. You're not managing cleanup — you're declaring scope.

If a process wasn't explicitly handed a secret through tsafe exec, it doesn't have it. That's not a policy. That's a property of how the execution works.