Lade
Temporary access to secrets, files, and private networks for one command, then gone. Same wrap for humans and agents. See which access was used.
Lade (/leɪd/) on Fish,
Bash, or Zsh.
macOS and Linux. Secrets from 1Password CLI,
Infisical, Doppler,
Vault,
Passbolt, files, shell commands, or inline values.
Forwards through kubectl, kubefwd, Teleport tsh, or SSH, only while the
command runs. Also CI, Cursor, Claude Code, Codex, and OpenCode.
Getting started
|
lade install writes pre-exec for this shell and pre-tool for detected
agents (hook and skill together). Then write a lade.yml (next section).
Pause and resume pre-exec with lade off and lade on.
Alternative installs:
Upgrade with:
How it works
Create a lade.yml at your project root. Lade walks from the current
directory up to $HOME and merges every lade.yml it finds. Each
top-level key is a regular expression matched against the command
being run.
"psql .*":
DB_USER: op://my.1password.com/eng/postgres/username
DB_PORT: kubectl://k8s.example.com:6443/prod/default/service/postgres/5432
DATABASE_URL: postgres://${DB_USER}@127.0.0.1:${DB_PORT}/app
Now run the command normally:
Lade resolves DB_USER, opens a local forward for DB_PORT, interpolates both
into DATABASE_URL, runs the command, masks resolved secret values from output,
and cleans up when psql exits.
Pre-exec is the recommended path: you keep typing the command. Without it,
prefix with lade. The explicit form is lade inject <command>.
The wrap skips the user profile. Same argv as sh://.
Observability
Opt-in. A matching rule with log: true records that the command ran and
which public keys and vault URIs it used. Values are never stored. lade usage lists the rules that actually fired. Unused rules are omitted.
Details: docs/observability.md.
.:
.:
log: true
"^git status":
.:
log: false
"^npm run deploy":
API_TOKEN: op://prod/api/credential
Default window: last 90 days. --since / --until are durations back from
now (Ns | Nm | Nh | Nd | Nw | Nmonth, m is minutes). --limit is an
extra cap. Bare --limit 20 drops the 90-day default. --group command
counts by command text. Nothing prunes by itself. lade log --help prints
the database path. lade status prints path, count, and size.
Queries stay on the current git root (worktrees count). --all reads every
repo. --path scopes to another tree. lade log is typed commands. lade usage is matched rules in this tree, most frequent first, with the file
path and env / file / tunnel.
Common patterns
pre-exec - You type the command in this shell. A lade.yml match gets
secrets and tunnels for that process only.

Provider resolution - Only the matching rule's URIs load. Other vaults stay closed.

Manual injection - No pre-exec? Prefix with lade. Same wrap as
lade inject --.

Private networks - Local forward lives for the process. A numeric key is a fixed port.

Secrets as files - .file writes a temp JSON/YAML, then deletes it.

Per-user values - One lade.yml. lade user alice picks the map key.

Human approval - disclaimer withholds access until lade approve <code>.

Shell command provider - sh:// stdout is a secret.

Intermediate bindings - .TOKEN builds another value. The child never
sees .TOKEN.

AI agents
The agent types the command and never sees provider-resolved secrets. Prefer this repo so clones share the guard. To wire a hook by hand:
--scope user is this machine (CODEX_HOME for Codex). --harness is
required in the files. Auto-detect is a safety net. lade status prints
run \lade install`` on drift.
The equivalent project configs are:
Trust the Lade command in /hooks. An untrusted hook or
[features].hooks = false is a silent no-op. User file:
~/.codex/hooks.json.
Native OpenCode loads plugins, not Claude-style hooks.json. User:
~/.config/opencode/plugins/. The plugin runs lade hook on
tool.execute.before and applies the rewritten command.
import from "node:child_process";
const lade = process.env.LADE_BIN ?? "lade";
export const ;
APM
That pin is a GitHub tag. It installs the skill
(.agents/skills/lade/SKILL.md).
Hooks stay in the files above or lade hook install.
Agents without pre-tool
Add a short instruction to AGENTS.md:
When a command needs access defined in lade.yml, prefix it with lade.
Example: lade terraform apply
Pre-tool is preferred: the agent does not guess which commands match.
MCP
A desktop MCP client launches a server without your shell's secret manager.
lade mcp is the command the client runs. It hydrates only for that
connection, then exits and cleans up when the connection closes.
Add a server entry in your MCP client's configuration. Use the absolute path to
the installed lade binary when a GUI application does not inherit your shell
PATH.
For a local stdio server, all public mappings become child environment variables:
Match the canonical server command in lade.yml:
"^acme-mcp --stdio$":
API_TOKEN: op://company/acme/api-token
For a remote Streamable HTTP server, public mapping keys become HTTP header names. The URL itself is the matcher:
"^https://mcp\\.secureframe\\.com/$":
.API_KEY: op://company/secureframe/api-key
.API_SECRET: op://company/secureframe/api-secret
Authorization: "${API_KEY} ${API_SECRET}"
.NAME is an intermediate binding (see below). Here .API_KEY never
becomes a header.
To troubleshoot an MCP connection, add -v before mcp in the client
configuration arguments. Lade writes action-only traces to stderr, such as
mcp http -> tools/call and mcp http <- 200 (42 ms). It never logs headers,
JSON-RPC parameters, request bodies, or resolved values. Use -vv for debug
logs; LADE_LOG overrides the command-line verbosity.
Configuration reference
Lade has two provider families used from the same lade.yml rule:
- Secret providers resolve values into environment variables or temporary files.
- Network providers open a local forward for the process, then close it.
Secrets
"terraform .*":
TF_VAR_api_key: op://DOMAIN/VAULT/ITEM/FIELD
Most secret providers call their CLI. Authenticate it first. Provider-resolved
values are masked unless --no-mask is set. Inline values are not masked:
they are already visible in lade.yml.
Supported secret providers:
| Provider | URI | Notes |
|---|---|---|
| 1Password | op://DOMAIN/VAULT/ITEM/FIELD |
Optional section: op://DOMAIN/VAULT/ITEM/SECTION/FIELD. Uses the 1Password CLI. |
| Infisical | infisical://DOMAIN/PROJECT_ID/ENV_NAME/SECRET_NAME |
Nested secret names are allowed in the last path segment. The /api suffix is added automatically. |
| Doppler | doppler://DOMAIN/PROJECT_NAME/ENV_NAME/SECRET_NAME |
Uses the Doppler CLI. |
| Vault | vault://DOMAIN/MOUNT/KEY/FIELD |
Path segments are URL-decoded. Uses the Vault CLI. |
| Passbolt | passbolt://DOMAIN/RESOURCE_ID/FIELD |
Uses the Passbolt CLI. |
| File | file://PATH?query=.fields[0].field |
?query= is required. INI, JSON, YAML, and TOML. |
| Shell command | sh://gcloud auth print-access-token |
Also bash://, zsh://, and fish://. Wrap: fish --no-config, zsh -f, $BASH_ENV cleared. |
| Inline value | "visible-in-lade-yml" |
Use ! to force a raw value and !! to keep a leading !. |
Use lade eval <uri> to resolve one URI when debugging a provider.
file:// details:
?query=is a JSON path after the file is parsed (access_json). Examples:.token,.db.password,.fields[0].field,.section.passwordfor INI.- Path is relative to the
lade.ymldirectory, or absolute.~/and$HOME/expand to the user home. Spaces in the path must be percent-encoded (%20). - Extension selects the parser:
.json,.yaml/.yml,.toml,.ini. - A
file://URI without?query=is rejected.
sh:// / bash:// / zsh:// / fish:// details:
- Everything after
scheme://is the script. It cannot be empty. - Wrap argv, also used by
lade injectandlade hook. Always on. There is nolade.ymlor CLI flag to turn it off.
| Shell | Wrap argv |
|---|---|
| Fish | fish --no-config -c … |
| Zsh | zsh -f -c … |
| Bash / sh | bash -c … with $BASH_ENV unset |
--norc --noprofile are not used: they do not skip $BASH_ENV, and bash -c
does not read .bashrc or login profiles anyway. Pre-exec (lade set) still
evals in the live interactive shell, so the profile stays in play there.
lade status prints inject wrap: skips startup files and names the file or
BASH_ENV when it is present.
- Lade recognizes
$NAMEand${NAME}to build the dependency graph, then passes those resolved values as environment variables. The script text is not rewritten. Quote expansions ("$user") so values stay one argument. - Output is treated as a secret and masked like other provider-resolved values.
Bindings can compose URIs. ${NAME}, $NAME, and ${.NAME} pull another
binding in the same rule. YAML null or ~ on a key cancels a value inherited
from a parent lade.yml. A later matching rule overlays the same key.
Intermediate bindings
Use a .NAME binding when a resolved value only helps construct another
binding. It remains private to the one command invocation, while the public
binding is injected into the requested output:
"curl .*api\\.example\\.com.*":
.API_KEY: op://company/api/key
Authorization: "Bearer ${API_KEY}"
Here Authorization is injected; API_KEY is not. Private bindings can depend
on other bindings and are included in masking when their resolved values reach
a public value. The end-to-end terminal demo is
examples/tape/intermediate.exp.
Shell transforms
Same wrap and $NAME graph as the sh:// table. The script is not rewritten.
Example: compose Basic auth without injecting the password.
"curl .*api\\.example\\.com.*":
user: demo-user
.password: op://company/api/password
Authorization: 'sh://printf "Basic %s" "$(printf "%s:%s" "${user}" "$password" | base64 | tr -d "\n")"'
Files and disclaimers
Options under . configure the matched command itself.
"deploy .*":
.:
file: secrets.yml
disclaimer: "This command will use production credentials."
log: true
API_TOKEN: op://DOMAIN/VAULT/ITEM/FIELD
when is always (default), human, or agent. lade hook and
--pretool are agent. lade set / unset are human. Otherwise env
signals (AI_AGENT, CURSOR_AGENT, CLAUDECODE, not CURSOR_VERSION)
select agent. The same pattern can be a YAML list of these blocks when
when differs. silence skips that rule's secret progress lines.
"^git ":
- .:
when: human
SSH_AUTH_SOCK: sh://launchctl getenv SSH_AUTH_SOCK
- .:
when: agent
SSH_AUTH_SOCK: 'sh://printf %s "$HOME/.ssh/agent.sock"'
With hooks, disclaimers cannot prompt for input. Lade withholds access and
prints an approval code; review it, then run lade approve <code> or re-run the
command with LADE_APPROVE=<code>.
Per-user values
"deploy .*":
API_TOKEN:
alice: op://DOMAIN/VAULT/ALICE_TOKEN/FIELD
ci: vault://DOMAIN/MOUNT/ci-token/value
.: op://DOMAIN/VAULT/DEFAULT_TOKEN/FIELD
Networks
Assign a URI to an environment variable for a dynamic local port, or to a number for a fixed local port.
"psql .*":
DB_PORT: kubectl://k8s.example.com:6443/prod/default/service/postgres/5432
1223: ssh://jump.example.com:22/db.internal/5432
A numeric key is the local listen port. An env-var key gets an ephemeral local
port unless local= sets one. Without local=, Lade binds 127.0.0.1.
Userinfo (user:pass@) is rejected. Unknown query keys fail instead of being
ignored. A malformed network URI fails closed. It is not treated as a raw
string.
Supported network providers:
| Provider | URI | Query options |
|---|---|---|
kubectl |
kubectl://<cluster-host>:<cluster-port>/<context-selector>/<namespace>/<kind>/<name>/<remote-port> |
local=HOST:PORT, pod-running-timeout=<duration> |
kubefwd |
kubefwd://<cluster-host>:<cluster-port>/<context-selector>/<namespace>/<kind>/<name>/<service-port> |
local=HOST:PORT, domain=<domain>, selector=<selector> |
tsh |
tsh://<proxy-host>:<proxy-port>/<kind>/<resource-path> |
local=HOST:PORT |
ssh |
ssh://<jump-host>:<jump-port>/<remote-host>/<remote-port> |
local=HOST:PORT |
Query options:
local=HOST:PORT: bind that local endpoint. Both parts are required. On a numeric key,PORTmust match the key. On an env-var key, that port is written into the variable.tshapp proxy accepts only127.0.0.1orlocalhost.pod-running-timeout(kubectlonly): passed through tokubectl port-forward --pod-running-timeout.domain/selector(kubefwdonly): forwarded tokubefwd.
For tsh, <kind> uses Teleport resource nomenclature:
app/<app-name>: Teleport app proxy (for example Grafana).app/<app-name>/<target-port>: same, with an explicit target port.kube_cluster/<kube-cluster>/<namespace>/<resource-kind>/<name>/<remote-port>: forward a Kubernetes resource through Teleport.
ssh jump port defaults to 22 when the authority has no port
(ssh://jump.example.com/db.internal/5432).
See examples/tape/lade.yml and examples/tape/network.txt for more examples.
In CI, OP_SERVICE_ACCOUNT_TOKEN is usually injected directly by the platform.
If the token itself lives in another vault, add 1password_service_account to
the . block. Lade resolves that URI first and uses it while resolving
remaining op:// secrets.
"deploy .*":
.:
1password_service_account: vault://DOMAIN/MOUNT/KEY/FIELD
API_TOKEN: op://DOMAIN/VAULT/ITEM/FIELD
CI and containers
The installer runs non-interactively in CI when CI=1, ASSUME_YES=1, or stdin
is not a TTY.
| CI=1
GitHub Actions
steps:
- uses: zifeo/lade@v0.15.3
with:
version: "0.15.3"
- run: lade inject -- terraform apply
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
GitLab CI
deploy:
script:
- curl -fsSL https://raw.githubusercontent.com/zifeo/lade/main/installer.sh | CI=1 VERSION=0.15.3 bash
- lade inject -- terraform apply
Docker
COPY --from=ghcr.io/zifeo/lade:0.15.3 /usr/local/bin/lade /usr/local/bin/lade
The ghcr.io/zifeo/lade image is published for linux/amd64 and linux/arm64
with tags X.Y.Z, X.Y, and latest. Pin an exact X.Y.Z for reproducible
builds.
See docs/ for internals.
Development