kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
165 lines (138 loc) • 8.43 kB
Markdown
# OSS-ADR-0054 — The shared out-of-tree secrets home (`~/.kestrel/.env`)
Status: accepted (2026-07-18, owner-ratified 2026-07-17). Names and extends
epic `kestrel-jh9w` (CLI-managed credential residency); folds in the loading
half of `kestrel-63r1` and the "no secret file in the tree" invariant of
`kestrel-jh9w.5` / `kestrel-933z`.
## Context
Local development spans **30+ worktrees across two repos** (`~/projects/kestrel`
and `~/projects/kestrel.markets`, plus their worktree roots). Dev/test API keys
have historically lived as per-checkout `.env` files. That has two standing
costs:
1. **Propagation.** A key configured in one worktree is invisible to the other
29 — every fresh worktree re-exports the same secrets, and an agent working
across context windows must re-establish them every session.
2. **Leak surface.** The OSS package's `package.json` `files` whitelist ships
`src/` and `examples/`. A `.env` misplaced anywhere under a whitelisted
directory **would publish to npm** — and a published tarball is immutable.
`git`-tree copies are a commit-leak risk in parallel; a gitignored `.env`
under `src/` is *still* shipped by npm (`git`-ignore is not `npm`-ignore).
The CLI already establishes a single owner-only home — `src/cli/credentials.ts`
creates `~/.kestrel` (`0700`) and writes `credentials.json` / `agent-key.json`
(`0600`), with a `KESTREL_HOME` override that keeps tests hermetic. The operator
secret store (`~/.kestrel/.env`, `jh9w.1`/`63r1`) and its CLI verbs
(`kestrel secrets set|list|unset|path`, `jh9w.2`) already ride that home. This
ADR ratifies the *residency policy* those pieces implement, and closes the two
gaps that remained: a process-environment **loading shim** and a fail-closed
**no-secret-file-in-the-tree** guard.
## Decision
**All local dev/test secrets live in one shared out-of-tree home,
`~/.kestrel/.env`. No repo tree ever carries a secret file — not even a
gitignored one.**
1. **Home.** `~/.kestrel/.env`, file `0600`, dir `0700`, ensured by the existing
`credentials.ts` machinery (atomic temp-and-rename writes; `KESTREL_HOME`
override for hermetic tests). Plain dotenv format so tooling in both repos can
source it.
2. **Precedence — `process.env` wins; `~/.kestrel/.env` is the fallback.** An
explicitly-exported env var always overrides the at-rest store. This is the
12-factor rule and it is what keeps CI (GitHub Actions secrets) and production
(`wrangler secret`) **untouched**: they inject via `process.env`, so the store
is never consulted there.
3. **The loading shim.** `loadEnvFallback()` (in `credentials.ts`) reads the
store once and populates any **UNSET** `process.env` key; a set key is left
exactly as-is. It is fail-quiet and dependency-light (node built-ins only),
never logs a value, and respects `KESTREL_HOME`. It is wired into the OSS
entrypoints that read these keys: the CLI router (`src/cli/index.ts` — the
`kestrel paper` path resolves the IBKR gateway config from `process.env`) and
the IBKR harness probes (`src/adapters/broker/ibkr/*-smoke.ts`,
`order-dryrun.ts`, `equity-order.ts`, `tick-probe.ts`). The bench data-pull
scripts already resolve through the per-key form (`loadSecret`,
`scripts/bench/lib/resolve-secret.ts`, `kestrel-3oei`).
4. **Contents (dev/paper only).** `ALPACA_PAPER_API_KEY_ID`,
`ALPACA_PAPER_SECRET_KEY`, `DATABENTO_API_KEY`, the GPU-cloud training key
(`n_CLOUD_KEY` — the public alias the repo already uses; the literal provider
name stays obfuscated per the kestrel-djm.10 launch gate),
`CLOUDFLARE_PIPELINE_TOKEN`, and the IBKR identifiers (`KESTREL_IBKR_*`).
BYO-model keys (`FIREWORKS_API_KEY`, `AZURE_OPENAI_*`, `LOCAL_LLM_*`) ride the
same home (credential residency, `jh9w`).
5. **NEVER in `~/.kestrel/.env` (paper-only doctrine).**
- **Live broker keys** — `wrangler`-only, behind `ALPACA_LIVE_ENABLED` + an
allowlist. `jh9w.5` refuses storing any `*_LIVE_*`-named key with a pointer
to `wrangler`.
- **Platform production secrets** — Cloudflare `wrangler secret put`.
- **CI credentials** — GitHub Actions secrets.
6. **The no-secret-file invariant, enforced.** `scripts/check-no-secret-files.ts`
(in `bun run check` and CI) fails closed if a `.env`-style file carrying an
API-key-shaped assignment appears **anywhere in the tracked tree or the npm
shipped set** — regardless of whether the value matches a known vendor shape.
Sanctioned scaffolding (`.env.example` / `.sample` / `.template` / `.dist`,
and placeholder-only values) does not trip it. This is complementary to, not a
replacement for, the shape-based `check-public-provenance.ts` credential scan
(which catches *known-shaped* key material in the shipped set) and the
`kestrel-933z` prepack tripwire.
## Considered and rejected
- **Per-repo loader shims.** One `.env` loader per repo re-introduces the
propagation problem this ADR exists to kill, and multiplies the surface where a
secret file could sit. `jh9w.4`'s `kestrel env -- <cmd>` exec wrapper is the
cross-**process** complement (e.g. `kestrel env -- wrangler dev` on the
platform, tracked as a `kestrel.markets` bead) so external tools that only read
a project `.env` get the same resolution without a second in-tree file.
- **A secret file in the repo, gitignored.** Rejected outright: npm ships
gitignored files inside a whitelisted `files[]` dir, so "gitignored" is not
"unpublished." The invariant is *no secret file in the tree at all*.
- **Store-hydration inside the pure config resolver** (`resolveIbkrConfig`).
Rejected: it would mutate `process.env` as a side effect of a pure resolve and
muddy the injected-env test seam. Hydration belongs at the entrypoint.
## Consequences
- Configure a key once (`kestrel secrets set KEY`), and every worktree of both
repos — and every future agent context — reads it. Zero secret files in any
checkout.
- Determinism is untouched: `loadEnvFallback` only fills config env (never a
graded runtime input), only when unset, and is a no-op under the hermetic
`KESTREL_HOME` tests use.
- The guard makes the invariant structural, not conventional.
## Appendix — operator migration checklist (run by a human; never by an agent)
An agent must never read, print, commit, or handle a real secret value. These
steps are for the human operator to consolidate scattered keys into the shared
home and delete the copies.
1. **Create the home (idempotent).**
```sh
mkdir -p ~/.kestrel && chmod 700 ~/.kestrel
```
2. **Import each key** with the CLI (value via prompt or `--stdin`, never argv —
argv leaks to shell history / `ps` / supervisor logs). Use the canonical
names:
```sh
kestrel secrets set ALPACA_PAPER_API_KEY_ID # paper only
kestrel secrets set ALPACA_PAPER_SECRET_KEY # paper only
kestrel secrets set DATABENTO_API_KEY
kestrel secrets set n_CLOUD_KEY # GPU-cloud training key
kestrel secrets set CLOUDFLARE_PIPELINE_TOKEN
kestrel secrets set KESTREL_IBKR_HOST # + PORT / CLIENT_ID / ACCOUNT as used
```
Or pipe from an existing file line without echoing it:
```sh
printf %s "$VALUE" | kestrel secrets set DATABENTO_API_KEY --stdin
```
3. **Verify** names only (values are never printed):
```sh
kestrel secrets list
kestrel secrets path # prints ~/.kestrel/.env
```
4. **Harden permissions** (the CLI already writes `0600`/`0700`; confirm):
```sh
chmod 600 ~/.kestrel/.env
chmod 700 ~/.kestrel
```
5. **Delete the scattered copies** once the store resolves them — every
`0dte/.env`, every per-worktree `.env`, any `~/projects/*/.env` holding dev
keys. Confirm nothing in a repo tree still holds a secret:
```sh
bun run check # runs check-no-secret-files.ts (fails closed on a tracked/shipped .env secret)
```
The guard's scope is the tracked tree ∪ the npm shipped set (the two leak
vectors). A purely-local `.env` that is gitignored, untracked, *and* outside
`files[]` is not caught by the guard — deleting it is exactly what this step
is for.
6. **Live and production keys do NOT move here.** Live broker keys stay in
`wrangler` (behind `ALPACA_LIVE_ENABLED` + allowlist); platform prod secrets
stay in `wrangler secret`; CI creds stay in GitHub Actions secrets.