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
text/typescript
/**
* # 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;
}