UNPKG

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.

418 lines (386 loc) 20.7 kB
/** * # 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 on-disk credential shape (v1). `capability` is the durable bearer token. */ export interface StoredCredential { readonly version: 1; /** The API base the credential was minted against — a credential is host-scoped. */ readonly api: string; readonly agent_id: string; readonly capability: string; readonly token_type: string; readonly scopes: readonly string[]; /** ISO-8601 expiry of the capability. */ readonly expiry: string; /** * The capability SUBJECT the durable token binds to (PLAT-ADR-0021). Stored so the * keypair-JWT auth path (cli/keypair.ts) can set the JWT `sub` the platform resolves the * enrolled key by. Present on credentials minted by a platform that returns `subject`. */ readonly subject?: string; /** * True once `kestrel register --keypair` enrolled an Ed25519 key for this credential * (kestrel-markets-0t0 slice 2). The private key lives in the sibling keyfile * ({@link defaultKeypairPath}); when it is present the CLI authenticates by minting * short-lived JWTs instead of replaying this durable capability. */ readonly keypair_enrolled?: boolean; /** * What the CLI DISCLOSED and sent as CLAIMED (unverified) git attribution, kept for * transparency (AX doctrine) — this is never trusted as an identity, here or server-side. */ readonly claimed?: { readonly name?: string; readonly email?: string }; } /** * The on-disk agent-keypair file (v1) — the CLI's SECOND at-rest secret (the enrolled * private key), stored 0600 ALONGSIDE credentials.json in `~/.kestrel/`. The private key * never leaves the machine; the CLI signs short-lived request JWTs with it. `audience` + * `max_ttl_seconds` are captured from the platform's enrollment response so the minted JWT * targets exactly the audience the platform verifies against. */ export interface StoredKeypair { readonly version: 1; /** The API base the key was enrolled against — host-scoped, like the credential. */ readonly api: string; /** The agent subject the JWT `sub` must carry (the platform's enrolled-key lookup key). */ readonly subject: string; readonly public_jwk: { readonly kty: "OKP"; readonly crv: "Ed25519"; readonly x: string }; /** The PRIVATE JWK — the only truly-secret bytes; the file is 0600 for this reason. */ readonly private_jwk: { readonly kty: "OKP"; readonly crv: "Ed25519"; readonly x: string; readonly d: string }; /** The audience the platform expects on a request JWT (from the enrollment response). */ readonly audience: string; /** The max JWT TTL (seconds) the platform accepts (from the enrollment response). */ readonly max_ttl_seconds: number; /** When the key was enrolled (ISO-8601). */ readonly enrolled_at: string; } /** The env this module reads (only `KESTREL_HOME`, for test isolation). */ interface Env { readonly [k: string]: string | undefined; } /** The default credential path: `$KESTREL_HOME/.kestrel/credentials.json` (else `~`). */ export function defaultCredentialsPath(env: Env = process.env): string { 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: Env = process.env): string { 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: StoredCredential, path: string = defaultCredentialsPath()): void { 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: StoredKeypair, path: string = defaultKeypairPath()): void { 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: string = defaultKeypairPath()): StoredKeypair | undefined { try { if (!existsSync(path)) return undefined; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredKeypair>; 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 as StoredKeypair; } 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: string = defaultCredentialsPath()): StoredCredential | undefined { try { if (!existsSync(path)) return undefined; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredCredential>; if (typeof parsed?.capability === "string" && parsed.capability.length > 0 && typeof parsed.api === "string") { return parsed as StoredCredential; } 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: Env = process.env): string { 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: string): void { if (!SECRET_KEY_RE.test(key)) { throw new Error(`kestrel: invalid secret key ${JSON.stringify(key)} — must match ${SECRET_KEY_RE}`); } } // ───────────────────────────────────────────────────────────────────────────── // The OPERATOR-SURFACE key policy (kestrel-jh9w.5.1) — the SINGLE source of truth EVERY face that // MINTS a secret name shares (the `kestrel secrets set` CLI verb AND the local MCP `kestrel.secrets.set` // tool). Keeping the two refusals here — not inline in one face — is what stops the wiring-evasion shape // where a guard is wired to only ONE face and inert on the other. It is intentionally STRICTER than the // store's own mixed-case sourceability check ({@link assertValidSecretKey}): the operator surface mints // only canonical UPPERCASE names, and refuses a live-broker key outright (paper store is paper-only). // // PURE — no I/O, no throw: it returns a typed VIOLATION descriptor (or `undefined` when the key is // storable) so each face renders it in its own dialect (a CLI `CliError` with an exit code, an in-band // MCP typed refusal). Because both faces route through this ONE function, a single edit here (e.g. // neutering it to always return `undefined`) goes red on BOTH faces' fixtures at once. /** The stable, agent-matchable codes an operator-surface key can be refused with. */ export type SecretKeyViolationCode = "SECRET_LIVE_KEY_REFUSED" | "SECRET_INVALID_KEY_NAME"; /** A refused operator-surface key: a stable `code`, a redacted `message`, and an actionable `hint`. */ export interface SecretKeyViolation { readonly code: SecretKeyViolationCode; readonly message: string; readonly hint: string; } /** * 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: string): SecretKeyViolation | undefined { // ── 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: string): boolean { return value.length === 0 || /[\s#"'`$\\]/.test(value); } /** Render one `KEY=value` line, double-quoting + escaping when the value demands it. */ function serializeSecretLine(key: string, value: string): string { 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: Map<string, string>): string { 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: string): Map<string, string> { const out = new Map<string, string>(); 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: string): Map<string, string> { 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: string, store: Map<string, string>): void { 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: string, value: string, path: string = defaultSecretsPath()): void { 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: string, opts: { env?: Env; path?: string } = {}): string | undefined { 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: string = defaultSecretsPath()): Record<string, string> { 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: string = defaultSecretsPath()): string[] { 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: string, path: string = defaultSecretsPath()): boolean { assertValidSecretKey(key); const store = readSecretStore(path); if (!store.delete(key)) return false; writeSecretStore(path, store); return true; } /** A mutable environment the shim populates — `process.env` by default; injected for tests. */ type MutableEnv = { [k: string]: string | undefined }; /** * 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: { env?: MutableEnv; path?: string } = {}): string[] { const env = opts.env ?? (process.env as MutableEnv); const path = opts.path ?? defaultSecretsPath(env); const populated: string[] = []; 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(); }