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.
320 lines (319 loc) • 15.7 kB
JavaScript
/**
* # cli/credentials — the registered-agent credential store (kestrel-markets-0t0).
*
* When `kestrel register` self-registers an autonomous agent (PLAT-ADR-0021 tier 1),
* the durable capability it receives is persisted HERE so subsequent `--api` calls
* present it as `Authorization: Bearer <capability>` instead of self-minting a fresh
* anonymous trial each time. This is the CLI's ONLY at-rest secret, so it lives in a
* single, owner-only file.
*
* There is no pre-existing CLI config-dir idiom in this repo (only the lake cache uses
* `homedir()`), so this establishes one: `~/.kestrel/credentials.json`, created 0700
* dir / 0600 file — the credential is a bearer token; a world-readable file would leak
* it. `KESTREL_HOME` overrides the base dir (used by tests to stay hermetic).
*
* Dependency-light on purpose (node built-ins only): this module is imported on the
* light command path (backend selection reads the stored credential), so it must not
* pull `bun:sqlite` or any heavy runtime.
*/
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, renameSync } from "node:fs";
/** The default credential path: `$KESTREL_HOME/.kestrel/credentials.json` (else `~`). */
export function defaultCredentialsPath(env = process.env) {
const home = env.KESTREL_HOME ?? homedir();
return join(home, ".kestrel", "credentials.json");
}
/** The default keyfile path: `$KESTREL_HOME/.kestrel/agent-key.json` (else `~`). */
export function defaultKeypairPath(env = process.env) {
const home = env.KESTREL_HOME ?? homedir();
return join(home, ".kestrel", "agent-key.json");
}
/**
* Persist a credential, owner-only. Creates `~/.kestrel` 0700 and writes the file 0600,
* then chmods it explicitly so a pre-existing file with looser perms is tightened too
* (the create-mode is not applied to an existing file).
*/
export function saveCredential(cred, path = defaultCredentialsPath()) {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(path, JSON.stringify(cred, null, 2) + "\n", { mode: 0o600 });
chmodSync(path, 0o600);
}
/**
* Persist the agent keypair file, owner-only (0600) in the same 0700 `~/.kestrel` dir.
* The private key is a secret at least as sensitive as the bearer capability, so the file
* gets the identical hardened perms (and an explicit chmod to tighten a pre-existing file).
*/
export function saveKeypair(keypair, path = defaultKeypairPath()) {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(path, JSON.stringify(keypair, null, 2) + "\n", { mode: 0o600 });
chmodSync(path, 0o600);
}
/**
* Load the stored keypair, or `undefined` if absent/unreadable/malformed. Best-effort +
* fail-quiet: a missing/corrupt keyfile just means "no keypair" (the caller falls back to
* the durable capability or an anonymous trial), never a crash on the command path.
*/
export function loadKeypair(path = defaultKeypairPath()) {
try {
if (!existsSync(path))
return undefined;
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (typeof parsed?.subject === "string" &&
typeof parsed.api === "string" &&
typeof parsed.audience === "string" &&
parsed.private_jwk !== undefined &&
typeof parsed.private_jwk.d === "string") {
return parsed;
}
return undefined;
}
catch {
return undefined;
}
}
/**
* Load the stored credential, or `undefined` if absent/unreadable/malformed. Best-effort
* and fail-quiet: a corrupt or missing file simply means "no stored credential" (the
* caller falls back to an anonymous trial), never a crash on the command path.
*/
export function loadCredential(path = defaultCredentialsPath()) {
try {
if (!existsSync(path))
return undefined;
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (typeof parsed?.capability === "string" && parsed.capability.length > 0 && typeof parsed.api === "string") {
return parsed;
}
return undefined;
}
catch {
return undefined;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Operator-secrets store — `~/.kestrel/.env` (kestrel-jh9w.1)
//
// A plain **dotenv** file (`KEY=value` per line), deliberately sourceable by tooling in
// both repos, living in the SAME owner-only `~/.kestrel` dir this module already
// establishes (0700 dir / 0600 file). It holds operator/BYOK secrets (e.g. an LLM API key)
// that the CLI reads as a FALLBACK to the process environment.
//
// Loading precedence (honored by {@link loadSecret}): `process.env` WINS; the file is the
// fallback. This mirrors 12-factor: an explicitly-exported env var always overrides at-rest
// config, and lets CI / containers inject secrets without touching disk.
//
// Writes are ATOMIC (temp file + `rename`, both 0600): a reader never observes a truncated
// or empty `.env`, and a crash mid-write leaves the prior file intact — the rename is the
// single commit point. `KESTREL_HOME` overrides the base dir so tests stay hermetic.
//
// SECURITY: a secret is only ever written UNDER `~/.kestrel` (or `$KESTREL_HOME`), NEVER
// into a repo tree — the npm `files[]` whitelist ships `src/`, so a stray `.env` there would
// publish even if gitignored (kestrel-igzs). This store hard-codes the home dir for that
// reason.
/** A key must be a POSIX-shell-safe env name so the `.env` stays sourceable. */
const SECRET_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** The default operator-secrets path: `$KESTREL_HOME/.kestrel/.env` (else `~`). */
export function defaultSecretsPath(env = process.env) {
const home = env.KESTREL_HOME ?? homedir();
return join(home, ".kestrel", ".env");
}
/** Throw on a key that is not a valid, shell-sourceable env-var name (fail closed). */
function assertValidSecretKey(key) {
if (!SECRET_KEY_RE.test(key)) {
throw new Error(`kestrel: invalid secret key ${JSON.stringify(key)} — must match ${SECRET_KEY_RE}`);
}
}
/**
* Decide whether `key` may be MINTED as an operator secret name, applying the two jh9w.5.1 refusals in
* order: the paper-only live-broker refusal (OSS-ADR-0054 §5), then the canonical-uppercase format check.
* Returns the FIRST violation, or `undefined` when the key is storable. Never reads a value and never
* touches disk — a face calls this BEFORE it receives (or writes) any secret value, so a refusal leaks
* nothing. The regexes match the CLI's historical behavior exactly (case-insensitive, segment-bounded
* `LIVE`; strict `^[A-Z_][A-Z0-9_]*$`) so this refactor is byte-compatible with the shipped CLI codes.
*/
export function checkOperatorSecretKey(key) {
// ── The paper-only doctrine refusal (OSS-ADR-0054 §5). A live-broker key name — any `LIVE` env-name
// segment, e.g. ALPACA_LIVE_KEY — must NEVER land in the paper `~/.kestrel/.env` store; live keys live in
// `wrangler secret` behind ALPACA_LIVE_ENABLED + an allowlist. Matched case-insensitively and
// segment-bounded so a lowercase variant is still caught (rather than slipping past as a mere format
// error) while a legit key that merely CONTAINS the letters (e.g. DELIVERY_*) is not swept up.
if (/(^|_)LIVE(_|$)/i.test(key)) {
return {
code: "SECRET_LIVE_KEY_REFUSED",
message: `refusing to store live-broker key ${JSON.stringify(key)} in the paper store — ~/.kestrel/.env is paper-only (OSS-ADR-0054 §5)`,
hint: `a live key belongs in wrangler, never the paper store: kestrel env -- wrangler secret put ${key} (behind ALPACA_LIVE_ENABLED + allowlist)`,
};
}
// ── Key-name FORMAT validation. A stored key must be a canonical UPPERCASE shell env name
// ([A-Z_][A-Z0-9_]*) so the `.env` stays sourceable and names stay predictable across both repos.
// Anything else (lowercase, a dash, a leading digit) is refused BEFORE a value is ever read.
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) {
return {
code: "SECRET_INVALID_KEY_NAME",
message: `invalid secret key name ${JSON.stringify(key)} — must match [A-Z_][A-Z0-9_]* (a canonical uppercase env-var name)`,
hint: "use the canonical uppercase name, e.g. `kestrel secrets set DATABENTO_API_KEY`",
};
}
return undefined;
}
/** True if a value needs quoting to survive a POSIX `source` / a dotenv parser round-trip. */
function needsQuoting(value) {
return value.length === 0 || /[\s#"'`$\\]/.test(value);
}
/** Render one `KEY=value` line, double-quoting + escaping when the value demands it. */
function serializeSecretLine(key, value) {
if (!needsQuoting(value))
return `${key}=${value}`;
// Double-quote and escape the four chars the shell still interprets inside "…".
const escaped = value.replace(/([\\"$`])/g, "\\$1");
return `${key}="${escaped}"`;
}
/** Serialize a store to dotenv text (keys in insertion order), trailing newline. */
function serializeSecrets(store) {
let out = "";
for (const [k, v] of store)
out += serializeSecretLine(k, v) + "\n";
return out;
}
/**
* Parse dotenv text into a map. Blank lines and `#` comments are skipped; a value may be
* bare, single-quoted (literal), or double-quoted (with `\` `"` `$` backtick escapes). This
* is fail-quiet on a malformed line (it is skipped), never a throw — the store is read on
* the command path.
*/
function parseSecrets(text) {
const out = new Map();
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line.length === 0 || line.startsWith("#"))
continue;
const eq = line.indexOf("=");
if (eq <= 0)
continue;
const key = line.slice(0, eq).trim();
if (!SECRET_KEY_RE.test(key))
continue;
let val = line.slice(eq + 1).trim();
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') {
val = val.slice(1, -1).replace(/\\([\\"$`])/g, "$1");
}
else if (val.length >= 2 && val[0] === "'" && val[val.length - 1] === "'") {
val = val.slice(1, -1); // single quotes are literal
}
out.set(key, val);
}
return out;
}
/** Read the store into a map, or an empty map if absent/unreadable (fail-quiet). */
function readSecretStore(path) {
try {
if (!existsSync(path))
return new Map();
return parseSecrets(readFileSync(path, "utf8"));
}
catch {
return new Map();
}
}
/**
* Persist the whole store atomically, owner-only. Ensures `~/.kestrel` 0700, writes a
* sibling temp file 0600, then `rename`s it over the target (the atomic commit point) and
* re-chmods 0600 to tighten a pre-existing loose-perm file.
*/
function writeSecretStore(path, store) {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
// Same-dir temp so the rename is atomic (never crosses a filesystem boundary). The pid
// suffix avoids a collision between two concurrent CLI writers; there is no runtime-path
// RNG here (this is the CLI config store, not the deterministic engine path).
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, serializeSecrets(store), { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, path);
chmodSync(path, 0o600);
}
/**
* Set (or overwrite) one operator secret in `~/.kestrel/.env`, owner-only + atomically.
* Existing keys are preserved (read-modify-write). Throws on an invalid key BEFORE touching
* disk (fail closed — nothing is written on rejection).
*/
export function saveSecret(key, value, path = defaultSecretsPath()) {
assertValidSecretKey(key);
const store = readSecretStore(path);
store.set(key, value);
writeSecretStore(path, store);
}
/**
* Resolve one secret with the loading precedence `process.env` **wins**, the on-disk store
* is the fallback. Returns `undefined` when absent everywhere — never a silent default.
*/
export function loadSecret(key, opts = {}) {
const env = opts.env ?? process.env;
const fromEnv = env[key];
if (fromEnv !== undefined)
return fromEnv;
const path = opts.path ?? defaultSecretsPath(env);
return readSecretStore(path).get(key);
}
/** Read the ENTIRE on-disk store as a plain object (does not consult `process.env`). */
export function loadSecrets(path = defaultSecretsPath()) {
return Object.fromEntries(readSecretStore(path));
}
/**
* The NAMES held in the on-disk store, sorted — the values never leave this module on this
* path. `kestrel secrets list` is built on this rather than {@link loadSecrets} so a listing
* code path never even HOLDS a value to accidentally render (kestrel-jh9w.2).
*/
export function secretNames(path = defaultSecretsPath()) {
return [...readSecretStore(path).keys()].sort();
}
/**
* Remove one secret from the store, atomically. Returns `true` if the key was present (and is
* now gone), `false` if it was already absent — the caller decides whether an absent key is an
* error (the CLI reports it as NOT_FOUND rather than a silent success). An invalid key throws
* BEFORE touching disk, like {@link saveSecret}.
*/
export function deleteSecret(key, path = defaultSecretsPath()) {
assertValidSecretKey(key);
const store = readSecretStore(path);
if (!store.delete(key))
return false;
writeSecretStore(path, store);
return true;
}
/**
* Hydrate the process environment from the shared out-of-tree secrets home (OSS-ADR-0054).
*
* Reads `~/.kestrel/.env` (or `$KESTREL_HOME/.kestrel/.env`) ONCE and copies each stored key into
* `env` **only if that key is currently UNSET** — an already-present value always WINS, honoring the
* store's `process.env`-wins precedence (the same rule {@link loadSecret} enforces per-key, applied
* in bulk so an entrypoint can hydrate the whole environment before any downstream reader — the IBKR
* config resolver, a bench pull — consults `process.env`). This is the in-process complement to the
* `kestrel env -- <cmd>` exec wrapper (kestrel-jh9w.4), which resolves the same store for an external
* process.
*
* FAIL-QUIET and dependency-light (node built-ins only, via {@link readSecretStore}): an absent,
* unreadable, or malformed store simply hydrates nothing — never a throw on the command path, and
* NEVER a logged value. Under the hermetic `KESTREL_HOME` tests use (a temp home with no `.env`) it
* is a no-op, so it cannot perturb determinism or a fixture's environment.
*
* Returns the NAMES it populated (values never leave this module), sorted — useful for a one-line
* diagnostic that says WHICH keys came from the store without disclosing any secret.
*/
export function loadEnvFallback(opts = {}) {
const env = opts.env ?? process.env;
const path = opts.path ?? defaultSecretsPath(env);
const populated = [];
for (const [key, value] of readSecretStore(path)) {
// process.env WINS: an already-set key (even to the empty string, an explicit operator choice) is
// left untouched. Only a genuinely UNSET key is filled from the store.
if (env[key] === undefined) {
env[key] = value;
populated.push(key);
}
}
return populated.sort();
}