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.

248 lines (231 loc) 11.1 kB
/** * # cli/commands/secrets — `kestrel secrets set|list|unset|path` (kestrel-jh9w.2) * * The operator-facing face of the `~/.kestrel/.env` store landed in jh9w.1 * ({@link ../credentials.ts}). Four verbs, one rule: **a secret VALUE never appears in argv * and never appears in output.** * * - `set KEY` reads the value from an interactive prompt (echo suppressed) or from stdin with * `--stdin`. A value passed POSITIONALLY (`secrets set KEY sk-live-…`) is REFUSED with a * named code (`SECRET_VALUE_IN_ARGV`) before anything is written — argv lands in the shell's * history file, in `ps` output, and in any process-supervisor log, so accepting it would leak * the secret to three places the store exists to avoid. Refusing is the fail-closed choice: * the alternative (accept-and-warn) still leaks. * - `list` prints NAMES ONLY, sorted — it is built on {@link secretNames}, which never returns * a value, so no listing code path even HOLDS a value to accidentally render. * - `unset KEY` removes one key (absent ⇒ exit 3 NOT_FOUND, never a silent success). * - `path` prints the store location so an operator can inspect/rotate it by hand. * * Non-interactive + no `--stdin` is also a refusal (`SECRET_NO_INPUT`), not a hang and not a * silent empty value. Dependency-light (node built-ins only), so it stays on the LIGHT command * path with the rest of the credential surface. */ import { readFileSync } from "node:fs"; import { createInterface } from "node:readline"; import type { OutputCtx } from "../context.ts"; import { parseArgs } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { defaultSecretsPath, saveSecret, deleteSecret, secretNames, checkOperatorSecretKey } from "../credentials.ts"; /** Injected seams so the verb is hermetically testable (no real home, no real tty/stdin). */ export interface SecretsDeps { /** Where the store lives; default `$KESTREL_HOME/.kestrel/.env` (else `~`). */ readonly secretsPath?: string; /** Read the whole of stdin (for `--stdin`); default reads fd 0. */ readonly readStdin?: () => string; /** Prompt for a value with echo suppressed; default a muted readline over the tty. */ readonly promptSecret?: (label: string) => Promise<string>; /** Environment (for KESTREL_HOME); default `process.env`. */ readonly env?: Record<string, string | undefined>; } function usage(message: string, hint?: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, ...(hint !== undefined ? { hint } : {}) }); } /** Read all of stdin as text (fd 0). Synchronous + dependency-free; `--stdin` is a one-shot pipe. */ function readStdinSync(): string { try { return readFileSync(0, "utf8"); } catch { // A closed / unreadable stdin is NOT an empty secret — fail closed rather than store "". throw new CliError({ code: "SECRET_NO_INPUT", exit: EXIT.USAGE, message: "--stdin was given but stdin could not be read", hint: "pipe the value: `printf %s \"$VALUE\" | kestrel secrets set KEY --stdin`", }); } } /** * Prompt for a secret on the tty with echo SUPPRESSED. The prompt itself goes to stderr so * stdout stays a clean payload channel, and nothing typed is echoed or ever re-printed. */ function promptSecretTty(label: string): Promise<string> { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); // Suppress echo: readline re-renders the line on every keystroke through `_writeToOutput`; // replacing it with a prompt-only write keeps the typed characters off the screen. (rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = (s: string) => { if (s.includes(label)) process.stderr.write(label); }; rl.question(label, (answer) => { rl.close(); process.stderr.write("\n"); resolve(answer); }); }); } /** A value read from stdin carries the pipe's trailing newline; strip exactly one. */ function stripOneTrailingNewline(s: string): string { if (s.endsWith("\r\n")) return s.slice(0, -2); if (s.endsWith("\n")) return s.slice(0, -1); return s; } /** * `kestrel secrets <verb>` — the operator secret store's CLI face. Dispatches the four verbs; * an unknown/absent verb is a loud USAGE error (never a default verb). */ export async function secretsCommand( argv: readonly string[], ctx: OutputCtx, deps: SecretsDeps = {}, ): Promise<number> { const parsed = parseArgs(argv, new Set(["stdin"]), new Set()); const env = deps.env ?? (process.env as Record<string, string | undefined>); const path = deps.secretsPath ?? defaultSecretsPath(env); const [verb, ...rest] = parsed.positionals; switch (verb) { case "set": return await setSecret(rest, parsed.bools.has("stdin"), ctx, path, deps); case "list": return listSecrets(rest, parsed.bools.has("stdin"), ctx, path); case "unset": return unsetSecret(rest, ctx, path); case "path": return printPath(rest, ctx, path); case undefined: throw usage("missing secrets verb", "one of: set | list | unset | path"); default: throw usage(`unknown secrets verb ${JSON.stringify(verb)}`, "one of: set | list | unset | path"); } } async function setSecret( rest: readonly string[], wantStdin: boolean, ctx: OutputCtx, path: string, deps: SecretsDeps, ): Promise<number> { const key = rest[0]; if (key === undefined) throw usage("missing KEY", "usage: kestrel secrets set KEY [--stdin]"); // ── The operator-surface key policy (kestrel-jh9w.5.1): the paper-only live-broker refusal // (OSS-ADR-0054 §5) then the canonical-uppercase format check. This is the SAME `checkOperatorSecretKey` // the local MCP `kestrel.secrets.set` tool applies — the guard is single-sourced (credentials.ts), never // wired to only one face. A violation is refused fail-closed (nothing is read from stdin, nothing is // written) with its stable named code so an agent matches on the code, not on prose. const keyViolation = checkOperatorSecretKey(key); if (keyViolation !== undefined) { throw new CliError({ code: keyViolation.code, exit: EXIT.USAGE, message: keyViolation.message, hint: keyViolation.hint }); } // ── The argv refusal. A value in argv has ALREADY leaked (shell history / `ps` / supervisor // logs) by the time we see it, so we refuse loudly and write NOTHING, rather than accepting it // and normalizing the habit. Named code so an agent can match on it, not on prose. if (rest.length > 1) { throw new CliError({ code: "SECRET_VALUE_IN_ARGV", exit: EXIT.USAGE, message: `refusing to read a secret value from argv for ${JSON.stringify(key)} — argv leaks into shell history, \`ps\`, and process logs`, hint: `pipe it instead: printf %s "$VALUE" | kestrel secrets set ${key} --stdin (or run \`kestrel secrets set ${key}\` on a tty to be prompted)`, }); } let value: string; if (wantStdin) { value = stripOneTrailingNewline((deps.readStdin ?? readStdinSync)()); } else if (ctx.interactive) { value = (await (deps.promptSecret ?? promptSecretTty)(`value for ${key}: `)).trim(); } else { // Fail closed: no tty to prompt on and no --stdin ⇒ there is no non-leaking way to receive // the value. Never hang waiting on a pipe that may never come, never store an empty string. throw new CliError({ code: "SECRET_NO_INPUT", exit: EXIT.USAGE, message: `no way to read the value for ${JSON.stringify(key)} — this session is not interactive and --stdin was not given`, hint: `printf %s "$VALUE" | kestrel secrets set ${key} --stdin`, }); } if (value.length === 0) { throw new CliError({ code: "SECRET_EMPTY", exit: EXIT.USAGE, message: `refusing to store an EMPTY value for ${JSON.stringify(key)}`, hint: `to remove it instead: kestrel secrets unset ${key}`, }); } saveSecret(key, value, path); // Every output path below is REDACTED — the confirmation names the key and the file, never the // value (a terminal scrollback / CI log is exactly where a secret must not land). if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.secrets.set/v1", key, path, stored: true }) + "\n"); return 0; } if (ctx.mode === "text") { process.stdout.write(`set\t${key}\t${path}\n`); return 0; } process.stdout.write(`stored ${key} in ${path} (value redacted, file 0600)\n`); return 0; } function listSecrets(rest: readonly string[], wantStdin: boolean, ctx: OutputCtx, path: string): number { if (rest.length > 0) throw usage(`unexpected argument ${JSON.stringify(rest[0])}`, "usage: kestrel secrets list"); if (wantStdin) throw usage("--stdin is only valid for `kestrel secrets set`"); // NAMES only — `secretNames` never returns a value, so this path cannot render one. const names = secretNames(path); if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.secrets.list/v1", path, names }) + "\n"); return 0; } if (ctx.mode === "text") { process.stdout.write(names.map((n) => `secret\t${n}\n`).join("")); return 0; } if (names.length === 0) { process.stdout.write(`no secrets stored in ${path}\n`); return 0; } process.stdout.write(`${names.length} secret(s) in ${path} (names only, values never printed)\n`); process.stdout.write(names.map((n) => ` ${n}\n`).join("")); return 0; } function unsetSecret(rest: readonly string[], ctx: OutputCtx, path: string): number { const key = rest[0]; if (key === undefined) throw usage("missing KEY", "usage: kestrel secrets unset KEY"); if (rest.length > 1) throw usage(`unexpected argument ${JSON.stringify(rest[1])}`, "usage: kestrel secrets unset KEY"); const removed = deleteSecret(key, path); if (!removed) { // Absent ⇒ a typed NOT_FOUND, never a silent success: an operator rotating a key must be able // to tell "removed" from "was never there under that name". throw new CliError({ code: "NOT_FOUND", exit: EXIT.NOT_FOUND, message: `no secret named ${JSON.stringify(key)} in ${path}`, hint: "run `kestrel secrets list` for the stored names", }); } if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.secrets.unset/v1", key, path, removed: true }) + "\n"); return 0; } if (ctx.mode === "text") { process.stdout.write(`unset\t${key}\t${path}\n`); return 0; } process.stdout.write(`removed ${key} from ${path}\n`); return 0; } function printPath(rest: readonly string[], ctx: OutputCtx, path: string): number { if (rest.length > 0) throw usage(`unexpected argument ${JSON.stringify(rest[0])}`, "usage: kestrel secrets path"); if (ctx.mode === "json") { process.stdout.write(JSON.stringify({ schema: "kestrel.secrets.path/v1", path }) + "\n"); return 0; } process.stdout.write(`${path}\n`); return 0; }