diff --git a/lit-agent-keychain/SECURITY.md b/lit-agent-keychain/SECURITY.md index d8e01cfc..401a5676 100644 --- a/lit-agent-keychain/SECURITY.md +++ b/lit-agent-keychain/SECURITY.md @@ -33,6 +33,20 @@ lookup (a lying RPC can only cause false rejections or accept a hash the Safe ne whitelisted). The policy constants are compiled into the client, so the same "verified client release" caveat above applies. +## Agent-side plaintext handling + +`get`, the `get_secret` MCP tool and `keychain run` all deliver plaintext to the +agent host; from there the client is trusted. `run` avoids stdout and passes the +value only through the child process's environment, which keeps it out of agent +transcripts and shell history but not out of reach of other processes running as +the same user (`ps eww`, `/proc//environ`). `--file` writes plaintext to a +mode-0600 file that is created before the child starts, never overwrites an existing +path, and is unlinked when the child exits; a SIGKILL of the CLI leaves it behind and +the bytes may survive on disk after unlink. Neither mode is a sandbox. The CLI zeroes +its copy of the agent key and drops its references to the fetched values once the +child has started; JavaScript strings cannot be scrubbed, so the plaintext may linger +in the CLI process heap briefly before it exits. + ## Accepted operator trust The database holds signed policy records and chooses the current record. The action diff --git a/lit-agent-keychain/SKILL.md b/lit-agent-keychain/SKILL.md index ef690361..00544584 100644 --- a/lit-agent-keychain/SKILL.md +++ b/lit-agent-keychain/SKILL.md @@ -14,7 +14,14 @@ version: 2.0.0 `use(name, input)` runs the secret's catalog action (Stripe balance, OpenAI chat, GitHub file read, Slack message, …) inside Lit without revealing its credential. `list()` tells you which applies to each secret and the input shape it takes. -4. Or expose it to an MCP client in one line. The server runs locally, next to the +4. For a tool that needs the raw value in its environment, prefer + `keychain run identity.json CONFIG.keychain.json -- ` over `get`. It + injects each export-release secret as an environment variable named after the + secret and prints nothing, so the value never enters your context or logs. + `--only A,B` selects secrets; `--env SECRET=ENV_VAR` renames one; + `--file SECRET=PATH` writes one to a new mode-0600 file that is removed when + the command exits, for tools that only read credentials from a path. +5. Or expose it to an MCP client in one line. The server runs locally, next to the identity file, and offers `list_secrets`, `get_secret`, one tool per catalog action (`stripe_balance`, `openai_chat`, `github_read_file`, `slack_post_message`), `list_actions` and `agent_public_key`: @@ -51,7 +58,8 @@ passed as a usage key, each with a message naming the mistake. Never request an owner's private key or Google token, and never ask the backend to mint a grant. There are no setup bearer tokens or managed per-tenant PKP vaults. Agent identity and Lit execution billing are separate. Keep identity and config files private -and avoid logging credentials returned by `get` or the CLI. +and avoid logging credentials returned by `get` or the CLI. When you only need a +credential for one command, use `keychain run` so it is never printed at all. The operator can replay older still-valid owner permissions, including undoing a revocation. It cannot invent new owner permissions. The frontend/SDK, Lit runtime, diff --git a/lit-agent-keychain/sdk/README.md b/lit-agent-keychain/sdk/README.md index 3f61ee2a..d36dbe8c 100644 --- a/lit-agent-keychain/sdk/README.md +++ b/lit-agent-keychain/sdk/README.md @@ -46,6 +46,41 @@ CLI reads write the requested result to stdout. Avoid sending credential output keychain get ./agent-identity.json ./API_KEY.keychain.json API_KEY ``` +For tools that need the raw credential in their environment, `run` skips stdout +entirely. It decrypts the export-release secrets in the config, places each in the +child's environment under the secret's name, hands the child your terminal, and exits +with the child's status. The value never appears in your shell history, agent +transcript, or logs: + +```sh +keychain run ./agent-identity.json ./STRIPE_API_KEY.keychain.json -- stripe balance retrieve +keychain run ./id.json ./db.keychain.json --only DATABASE_URL -- psql +keychain run ./id.json ./cfg.keychain.json --env "openai-prod=OPENAI_API_KEY" -- python agent.py +``` + +`--only A,B` injects a subset; `--env SECRET=ENV_VAR` renames a variable, which is +required when a secret's name is not a valid variable name. "Use inside Lit" +secrets have no value to inject and are skipped with a note on stderr; naming one +under `--only` is an error. The child inherits the parent environment. Any process +running as the same user can read another process's environment, so `run` is a +handoff to a tool you trust, not a sandbox. + +Tools that read credentials from a path (service-account JSON, kubeconfig, SSH and +TLS keys, `.npmrc`) take `--file SECRET=PATH`. The file is created with mode 0600 +before the command starts, is never overwritten if it already exists, and is removed +when the command exits. A `--file` secret stays out of the environment unless `--env` +names it too. Multi-line values such as PEM keys are written byte for byte. + +```sh +keychain run ./id.json ./gcp.keychain.json --file GCP_SA=/tmp/sa.json -- \ + env GOOGLE_APPLICATION_CREDENTIALS=/tmp/sa.json gcloud storage ls +keychain run ./id.json ./k8s.keychain.json --file KUBECONFIG_PROD=./kubeconfig -- \ + kubectl --kubeconfig ./kubeconfig get pods +``` + +If the CLI itself is killed with SIGKILL the file cannot be cleaned up; prefer a +tmpfs path such as `/dev/shm` on Linux for anything long-lived. + Set `CHIPOTLE_USAGE_API_KEY` for a CLI billing-key override, or pass `{ usageApiKey }` as the SDK constructor's third argument. After the owner replaces the execution key, update every agent using the old key. diff --git a/lit-agent-keychain/sdk/cli.mjs b/lit-agent-keychain/sdk/cli.mjs index 12eb5160..641dd505 100755 --- a/lit-agent-keychain/sdk/cli.mjs +++ b/lit-agent-keychain/sdk/cli.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { readFile, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; import { Keychain, ACTIONS, @@ -13,6 +14,7 @@ const usage = " keychain init \n" + " keychain get \n" + " keychain use [json-input]\n" + + " keychain run [--only A,B] [--env SECRET=ENV_VAR]... [--file SECRET=PATH]... -- [args...]\n" + " keychain actions\n" + " keychain mcp [more-config-files]\n" + " keychain attest [lit-api-url]\n" + @@ -20,6 +22,9 @@ const usage = "identity-file: JSON from `keychain init` ({ v, privateKey, publicKey }); keep private.\n" + "config-file: *.keychain.json downloaded from Keychain ({ v, litApiUrl, usageApiKey, secrets }).\n" + "use runs the secret's catalog action inside Lit (never revealing the value); actions lists the catalog.\n" + + "run decrypts export-release secrets into the command's environment (named after each secret) and\n" + + " exits with its status; nothing is printed. --only picks secrets, --env renames a variable, --file writes\n" + + " a secret to a new mode-0600 file (instead of the environment) that is removed when the command exits.\n" + "CHIPOTLE_USAGE_API_KEY overrides the config's scoped billing key.\n" + "KEYCHAIN_SKIP_ATTESTATION=1 disables the TEE attestation check (development only).\n"; const attestationOptions = async (litApiUrl) => @@ -91,6 +96,27 @@ try { } finally { client.destroy(); } + } else if (command === "run") { + const { parseRunArgs, runWithSecrets } = await import("./run.mjs"); + const options = parseRunArgs(args); + const identity = await readJson(options.identityFile); + assertAgentIdentity(identity); + const config = await readJson(options.configFile); + assertAgentConfig(config); + const client = new Keychain(identity.privateKey, config, { + usageApiKey: process.env.CHIPOTLE_USAGE_API_KEY, + ...(await attestationOptions(config.litApiUrl)), + }); + try { + process.exitCode = await runWithSecrets(client, options, { + spawn, + env: process.env, + stderr: process.stderr, + process, + }); + } finally { + client.destroy(); + } } else if (command === "attest" && args.length <= 1) { const { verifyAttestation, ATTESTED_ORIGINS, DEFAULT_LIT_API_URL } = await import("./dist/index.js"); diff --git a/lit-agent-keychain/sdk/package.json b/lit-agent-keychain/sdk/package.json index 67f4cb0a..8dc77e76 100644 --- a/lit-agent-keychain/sdk/package.json +++ b/lit-agent-keychain/sdk/package.json @@ -18,6 +18,8 @@ "cli.mjs", "mcp.mjs", "mcp.d.mts", + "run.mjs", + "run.d.mts", "tls.mjs", "README.md" ], diff --git a/lit-agent-keychain/sdk/run.d.mts b/lit-agent-keychain/sdk/run.d.mts new file mode 100644 index 00000000..2f5aa83d --- /dev/null +++ b/lit-agent-keychain/sdk/run.d.mts @@ -0,0 +1,51 @@ +import type { SpawnOptions } from "node:child_process"; + +/** Structural view of a child process so tests can substitute a fake. */ +export type ChildLike = { + once(event: string, handler: (...args: any[]) => void): unknown; + kill(signal?: NodeJS.Signals | number): unknown; +}; + +export interface RunOptions { + identityFile: string; + configFile: string; + /** Secret names to inject; null injects every export-release secret. */ + only: string[] | null; + /** Secret name to environment variable name. */ + rename: Record; + /** Secret name to file path; such secrets stay out of the environment unless also renamed. */ + files: Record; + /** Command and arguments after `--`. */ + command: string[]; +} + +export interface InjectionPlan { + plan: { name: string; envVar?: string; file?: string }[]; + skipped: string[]; +} + +export function parseRunArgs(args: string[]): RunOptions; + +export function planInjection( + list: { name: string; operation: string }[], + options: Pick & + Partial>, +): InjectionPlan; + +export function runWithSecrets( + client: { + list(): { name: string; operation: string }[]; + get(name: string): Promise; + destroy(): void; + }, + options: Pick, + io: { + spawn: (file: string, args: string[], options: SpawnOptions) => ChildLike; + env: NodeJS.ProcessEnv | Record; + stderr: { write(chunk: string): unknown }; + process: { + on(signal: string, handler: () => void): unknown; + off(signal: string, handler: () => void): unknown; + }; + }, +): Promise; diff --git a/lit-agent-keychain/sdk/run.mjs b/lit-agent-keychain/sdk/run.mjs new file mode 100644 index 00000000..60bd3255 --- /dev/null +++ b/lit-agent-keychain/sdk/run.mjs @@ -0,0 +1,223 @@ +// `keychain run`: decrypt export-release secrets and hand them to a child +// process as environment variables or mode-0600 files that live only as long +// as the child. Nothing is written to stdout by the CLI itself; the child owns +// stdio. Modelled on Bitwarden's `bws run`. +import { constants } from "node:os"; +import { open, unlink } from "node:fs/promises"; +import { resolve } from "node:path"; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Parses `run` arguments after the command word: + * [--only A,B] [--env SECRET=ENV_VAR]... [--file SECRET=PATH]... -- [args...] + */ +export function parseRunArgs(args) { + const separator = args.indexOf("--"); + if (separator === -1) + throw new Error("run needs `--` followed by the command to execute"); + const [identityFile, configFile, ...flags] = args.slice(0, separator); + const command = args.slice(separator + 1); + if (!identityFile || !configFile) + throw new Error("run needs before `--`"); + if (command.length === 0) throw new Error("run needs a command after `--`"); + let only = null; + const rename = {}; + const files = {}; + for (let i = 0; i < flags.length; i++) { + const flag = flags[i]; + const value = () => { + const next = flags[++i]; + if (next === undefined || next.startsWith("--")) + throw new Error(`${flag} needs a value`); + return next; + }; + if (flag === "--only") { + only = (only ?? []).concat( + value() + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ); + } else if (flag === "--env") { + const pair = value(); + const eq = pair.indexOf("="); + if (eq <= 0 || eq === pair.length - 1) + throw new Error(`--env expects SECRET_NAME=ENV_VAR, got ${pair}`); + const secret = pair.slice(0, eq); + const envVar = pair.slice(eq + 1); + if (!ENV_NAME.test(envVar)) + throw new Error(`${envVar} is not a valid environment variable name`); + rename[secret] = envVar; + } else if (flag === "--file") { + const pair = value(); + const eq = pair.indexOf("="); + if (eq <= 0 || eq === pair.length - 1) + throw new Error(`--file expects SECRET_NAME=PATH, got ${pair}`); + const secret = pair.slice(0, eq); + const path = pair.slice(eq + 1); + if (path.endsWith("/")) + throw new Error(`--file path for ${secret} must name a file`); + if (files[secret] !== undefined) + throw new Error(`--file given twice for "${secret}"`); + files[secret] = path; + } else { + throw new Error(`Unknown run option ${flag}`); + } + } + if (only !== null && only.length === 0) + throw new Error("--only needs at least one secret name"); + return { identityFile, configFile, only, rename, files, command }; +} + +/** + * Decides where each secret goes. A secret named by --file is written to that + * path and stays out of the environment unless --env names it too; every other + * export-release secret becomes a variable named after it. + * Returns { plan: [{ name, envVar?, file? }], skipped: [name] }. + */ +export function planInjection(list, { only, rename, files = {} }) { + const byName = new Map(list.map((secret) => [secret.name, secret])); + const wanted = only ?? list.map((secret) => secret.name); + for (const name of [...Object.keys(rename), ...Object.keys(files)]) { + if (!byName.has(name)) throw new Error(`Unknown secret "${name}"`); + if (only && !only.includes(name)) + throw new Error(`"${name}" is mapped but not listed under --only`); + } + const plan = []; + const skipped = []; + const usedVars = new Map(); + const usedPaths = new Map(); + for (const name of wanted) { + const secret = byName.get(name); + if (!secret) throw new Error(`Unknown secret "${name}"`); + if (secret.operation !== "get") { + if (only) + throw new Error( + `"${name}" is a "use inside Lit" secret (${secret.operation}); it has no value to inject. Use \`keychain use\`.`, + ); + skipped.push(name); + continue; + } + const entry = { name }; + if (files[name] !== undefined) { + const path = resolve(files[name]); + const clash = usedPaths.get(path); + if (clash !== undefined) + throw new Error(`Secrets "${clash}" and "${name}" both map to ${path}`); + usedPaths.set(path, name); + entry.file = path; + } + if (files[name] === undefined || rename[name] !== undefined) { + const envVar = rename[name] ?? name; + if (!ENV_NAME.test(envVar)) + throw new Error( + `Secret "${name}" is not a valid environment variable name; map it with --env ${name}=SOME_NAME or --file ${name}=PATH`, + ); + const clash = usedVars.get(envVar); + if (clash !== undefined && clash !== name) + throw new Error( + `Secrets "${clash}" and "${name}" both map to ${envVar}`, + ); + usedVars.set(envVar, name); + entry.envVar = envVar; + } + plan.push(entry); + } + if (plan.length === 0) + throw new Error( + only + ? "Nothing to inject" + : 'This config has no export-release secrets to inject; every secret is "use inside Lit"', + ); + return { plan, skipped }; +} + +/** + * Fetches the planned secrets through `client.get`, writes any --file targets + * (mode 0600, never overwriting), spawns the command with the rest in its + * environment, forwards termination signals, removes the files once the child + * exits, and resolves to the exit code the CLI should use. `client.destroy()` + * is called once the child has started so the agent key does not outlive the + * handoff. + */ +export async function runWithSecrets( + client, + { only, rename, files, command }, + { spawn, env, stderr, process: proc }, +) { + const { plan, skipped } = planInjection(client.list(), { + only, + rename, + files, + }); + for (const name of skipped) + stderr.write( + `Keychain: skipping "${name}" (use inside Lit only, no value to inject)\n`, + ); + const values = await Promise.all(plan.map(({ name }) => client.get(name))); + const written = []; + try { + for (const [i, { file }] of plan.entries()) { + if (file === undefined) continue; + let handle; + try { + handle = await open(file, "wx", 0o600); + } catch (error) { + throw new Error( + error.code === "EEXIST" + ? `${file} already exists; run will not overwrite it` + : `Cannot create ${file}: ${error.message}`, + ); + } + written.push(file); + try { + await handle.writeFile(values[i], "utf8"); + } finally { + await handle.close(); + } + } + const childEnv = { ...env }; + plan.forEach(({ envVar }, i) => { + if (envVar !== undefined) childEnv[envVar] = values[i]; + }); + values.fill(""); + const [file, ...args] = command; + const child = spawn(file, args, { env: childEnv, stdio: "inherit" }); + for (const key of Object.keys(childEnv)) delete childEnv[key]; + client.destroy(); + const signals = ["SIGINT", "SIGTERM", "SIGHUP"]; + const forward = signals.map((signal) => { + const handler = () => child.kill(signal); + proc.on(signal, handler); + return [signal, handler]; + }); + try { + return await new Promise((resolve, reject) => { + child.once("error", (error) => + reject(new Error(`Cannot start ${file}: ${error.message}`)), + ); + child.once("exit", (code, signal) => { + if (signal) { + // Mirror the shell convention so callers see why the child stopped. + stderr.write(`Keychain: ${file} terminated by ${signal}\n`); + resolve(128 + (constants.signals[signal] ?? 0)); + } else resolve(code ?? 1); + }); + }); + } finally { + for (const [signal, handler] of forward) proc.off(signal, handler); + } + } finally { + values.fill(""); + for (const file of written) { + try { + await unlink(file); + } catch (error) { + if (error.code !== "ENOENT") + stderr.write( + `Keychain: could not remove ${file}: ${error.message}\n`, + ); + } + } + } +} diff --git a/lit-agent-keychain/tests/api.test.ts b/lit-agent-keychain/tests/api.test.ts index 6304a397..64a48f53 100644 --- a/lit-agent-keychain/tests/api.test.ts +++ b/lit-agent-keychain/tests/api.test.ts @@ -1,6 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { subscribe } from "./billing-fixture.ts"; import { privateKeyToAccount } from "viem/accounts"; import { @@ -116,6 +119,107 @@ test( !body.includes(keys.privateKey), ), ); + // `keychain run` injects the value into a child's environment and prints + // nothing itself. + { + const dir = mkdtempSync(path.join(tmpdir(), "keychain-run-")); + try { + const identityFile = path.join(dir, "identity.json"); + const configFile = path.join(dir, "API_TEST.keychain.json"); + writeFileSync(identityFile, JSON.stringify({ v: 2, ...keys }), { + mode: 0o600, + }); + writeFileSync( + configFile, + JSON.stringify({ + v: 2, + litApiUrl: lit, + usageApiKey: c.lit.usageApiKey, + secrets: { + API_TEST: { + manifest: bundle.manifest.document.manifest, + actionCid: bundle.manifest.document.actionCid, + }, + }, + }), + ); + const stdout = execFileSync( + process.execPath, + [ + "sdk/cli.mjs", + "run", + identityFile, + configFile, + "--env", + "API_TEST=INJECTED", + "--", + process.execPath, + "-e", + 'process.stdout.write(JSON.stringify([process.env.INJECTED, "API_TEST" in process.env]))', + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, KEYCHAIN_SKIP_ATTESTATION: "1" }, + }, + ); + assert.deepEqual(JSON.parse(stdout), [ + "local-only-secret-7f9ba", + false, + ]); + // --file: private file for the child's lifetime, gone afterwards. + const secretFile = path.join(dir, "api-test.txt"); + const fromFile = execFileSync( + process.execPath, + [ + "sdk/cli.mjs", + "run", + identityFile, + configFile, + "--file", + `API_TEST=${secretFile}`, + "--", + process.execPath, + "-e", + `const fs=require("node:fs");process.stdout.write(JSON.stringify([fs.readFileSync(${JSON.stringify(secretFile)},"utf8"),(fs.statSync(${JSON.stringify(secretFile)}).mode&0o777).toString(8),"API_TEST" in process.env]))`, + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, KEYCHAIN_SKIP_ATTESTATION: "1" }, + }, + ); + assert.deepEqual(JSON.parse(fromFile), [ + "local-only-secret-7f9ba", + "600", + false, + ]); + assert.equal(existsSync(secretFile), false); + assert.throws( + () => + execFileSync( + process.execPath, + [ + "sdk/cli.mjs", + "run", + identityFile, + configFile, + "--", + process.execPath, + "-e", + "process.exit(7)", + ], + { + stdio: "pipe", + env: { ...process.env, KEYCHAIN_SKIP_ATTESTATION: "1" }, + }, + ), + (error: any) => error.status === 7, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } const stale = bundle; const earlyBackup = await c.backup(); bundle = await c.rotate(bundle, "rotated-only-in-browser"); diff --git a/lit-agent-keychain/tests/sdk-package.test.ts b/lit-agent-keychain/tests/sdk-package.test.ts index 4a56b991..018d2ef9 100644 --- a/lit-agent-keychain/tests/sdk-package.test.ts +++ b/lit-agent-keychain/tests/sdk-package.test.ts @@ -248,3 +248,336 @@ test("stdio MCP server speaks JSON-RPC, exposes tools, and never prints the priv rmSync(dir, { recursive: true, force: true }); } }); + +test("`keychain run` parses its arguments and refuses anything it cannot inject", async () => { + const { parseRunArgs, planInjection, runWithSecrets } = + await import("../sdk/run.mjs"); + assert.deepEqual( + parseRunArgs([ + "id.json", + "cfg.json", + "--only", + "A, B", + "--env", + "A=STRIPE_KEY", + "--", + "stripe", + "balance", + "--live", + ]), + { + identityFile: "id.json", + configFile: "cfg.json", + only: ["A", "B"], + rename: { A: "STRIPE_KEY" }, + files: {}, + command: ["stripe", "balance", "--live"], + }, + ); + assert.deepEqual( + parseRunArgs(["id", "cfg", "--file", "SA=./sa.json", "--", "gcloud"]).files, + { SA: "./sa.json" }, + ); + assert.throws( + () => parseRunArgs(["id", "cfg", "--file", "SA=/tmp/", "--", "x"]), + /must name a file/, + ); + assert.throws( + () => + parseRunArgs([ + "id", + "cfg", + "--file", + "SA=a", + "--file", + "SA=b", + "--", + "x", + ]), + /given twice/, + ); + assert.throws( + () => parseRunArgs(["id", "cfg", "--file", "SA", "--", "x"]), + /SECRET_NAME=PATH/, + ); + assert.throws(() => parseRunArgs(["id", "cfg", "echo"]), /`--`/); + assert.throws(() => parseRunArgs(["id", "cfg", "--"]), /command after/); + assert.throws(() => parseRunArgs(["id", "--", "echo"]), /identity-file/); + assert.throws( + () => parseRunArgs(["id", "cfg", "--env", "A=1BAD", "--", "x"]), + /not a valid environment variable/, + ); + assert.throws( + () => parseRunArgs(["id", "cfg", "--verbose", "--", "x"]), + /Unknown run option/, + ); + assert.throws( + () => parseRunArgs(["id", "cfg", "--only", "--", "x"]), + /needs a value/, + ); + + const list = [ + { name: "API_KEY", release: "export", operation: "get" }, + { name: "my-token", release: "export", operation: "get" }, + { name: "STRIPE", release: "stripe_balance", operation: "stripe.balance" }, + ]; + assert.deepEqual( + planInjection(list, { only: null, rename: { "my-token": "MY_TOKEN" } }), + { + plan: [ + { name: "API_KEY", envVar: "API_KEY" }, + { name: "my-token", envVar: "MY_TOKEN" }, + ], + skipped: ["STRIPE"], + }, + ); + // A --file secret stays out of the environment unless --env names it too, + // and an awkward name is fine when it only goes to a file. + assert.deepEqual( + planInjection(list, { + only: null, + rename: {}, + files: { "my-token": "/tmp/x/token" }, + }).plan, + [ + { name: "API_KEY", envVar: "API_KEY" }, + { name: "my-token", file: "/tmp/x/token" }, + ], + ); + assert.deepEqual( + planInjection(list, { + only: ["API_KEY"], + rename: { API_KEY: "KEY" }, + files: { API_KEY: "key.txt" }, + }).plan, + [{ name: "API_KEY", envVar: "KEY", file: path.resolve("key.txt") }], + ); + assert.throws( + () => + planInjection(list, { + only: null, + rename: {}, + files: { API_KEY: "/tmp/same", "my-token": "/tmp/same" }, + }), + /both map to \/tmp\/same/, + ); + assert.throws( + () => + planInjection(list, { + only: ["API_KEY"], + rename: {}, + files: { "my-token": "/tmp/t" }, + }), + /mapped but not listed under --only/, + ); + assert.throws( + () => planInjection(list, { only: null, rename: {} }), + /"my-token" is not a valid environment variable name/, + ); + assert.throws( + () => planInjection(list, { only: ["STRIPE"], rename: {} }), + /use inside Lit/, + ); + assert.throws( + () => planInjection(list, { only: ["NOPE"], rename: {} }), + /Unknown secret "NOPE"/, + ); + assert.throws( + () => planInjection(list, { only: null, rename: { NOPE: "X" } }), + /Unknown secret "NOPE"/, + ); + assert.throws( + () => + planInjection(list, { + only: ["API_KEY", "my-token"], + rename: { "my-token": "API_KEY" }, + }), + /both map to API_KEY/, + ); + assert.throws( + () => planInjection([list[2]], { only: null, rename: {} }), + /no export-release secrets/, + ); + + // The spawn contract: secrets land only in the child's env, the parent's + // env is untouched, the client is destroyed, and the child's exit code wins. + let destroyed = false; + const client = { + list: () => list, + get: async (name: string) => `value-of-${name}`, + destroy: () => { + destroyed = true; + }, + }; + const parentEnv = { PATH: "/bin" }; + const stderr: string[] = []; + const spawned: any[] = []; + const dir = mkdtempSync(path.join(tmpdir(), "keychain-run-file-")); + const tokenFile = path.join(dir, "token"); + const spawn = (file: string, args: string[], options: any) => { + // The file exists, holds the exact value, and is private while the child runs. + assert.equal(readFileSync(tokenFile, "utf8"), "value-of-my-token"); + assert.equal(statSync(tokenFile).mode & 0o777, 0o600); + spawned.push({ file, args, env: { ...options.env }, stdio: options.stdio }); + const handlers: Record = {}; + return { + once(event: string, handler: Function) { + handlers[event] = handler; + if (event === "exit") setImmediate(() => handler(3, null)); + }, + kill() {}, + }; + }; + const fakeProcess = { on() {}, off() {} }; + try { + const code = await runWithSecrets( + client, + { + only: null, + rename: {}, + files: { "my-token": tokenFile }, + command: ["env"], + }, + { + spawn, + env: parentEnv, + stderr: { write: (s: string) => stderr.push(s) }, + process: fakeProcess, + }, + ); + assert.equal(code, 3); + assert.equal(destroyed, true); + assert.deepEqual(parentEnv, { PATH: "/bin" }); + assert.deepEqual(spawned, [ + { + file: "env", + args: [], + env: { PATH: "/bin", API_KEY: "value-of-API_KEY" }, + stdio: "inherit", + }, + ]); + assert.match(stderr.join(""), /skipping "STRIPE"/); + assert.ok(!stderr.join("").includes("value-of")); + // Removed once the child exits. + assert.throws(() => statSync(tokenFile), /ENOENT/); + // Never overwrites, and nothing is spawned when a file cannot be created. + writeFileSync(tokenFile, "precious"); + await assert.rejects( + runWithSecrets( + client, + { + only: ["my-token"], + rename: {}, + files: { "my-token": tokenFile }, + command: ["env"], + }, + { + spawn: () => assert.fail("spawned despite file error"), + env: {}, + stderr: { write() {} }, + process: fakeProcess, + }, + ), + /already exists; run will not overwrite/, + ); + assert.equal(readFileSync(tokenFile, "utf8"), "precious"); + await assert.rejects( + runWithSecrets( + client, + { + only: ["API_KEY"], + rename: {}, + files: { API_KEY: path.join(dir, "missing-dir", "key") }, + command: ["env"], + }, + { + spawn: () => assert.fail("spawned despite file error"), + env: {}, + stderr: { write() {} }, + process: fakeProcess, + }, + ), + /Cannot create .*missing-dir/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + // A missing executable is reported without leaking anything. + await assert.rejects( + runWithSecrets( + client, + { + only: ["API_KEY"], + rename: {}, + files: {}, + command: ["/nonexistent/bin"], + }, + { + spawn: () => ({ + once(event: string, handler: Function) { + if (event === "error") + setImmediate(() => handler(new Error("ENOENT"))); + }, + kill() {}, + }), + env: {}, + stderr: { write() {} }, + process: fakeProcess, + }, + ), + /Cannot start \/nonexistent\/bin: ENOENT/, + ); +}); + +test("`keychain run` via the CLI rejects bad arguments before touching the network", () => { + const dir = mkdtempSync(path.join(tmpdir(), "keychain-run-")); + try { + const identityFile = path.join(dir, "identity.json"); + execFileSync(process.execPath, ["sdk/cli.mjs", "init", identityFile]); + const configFile = path.join(dir, "A.keychain.json"); + writeFileSync( + configFile, + JSON.stringify({ + v: 2, + litApiUrl: "http://localhost:8000", + usageApiKey: Buffer.alloc(32, 9).toString("base64"), + secrets: { + A: { + manifest: { + v: 2, + network: "test", + registry: "http://localhost:8001", + vaultId: "0".repeat(64), + authorityCid: "bafkreib" + "a".repeat(51), + secretId: "1".repeat(64), + release: "stripe_balance", + }, + actionCid: "bafkreic" + "b".repeat(51), + }, + }, + }), + ); + const attempt = (args: string[]) => { + try { + execFileSync(process.execPath, ["sdk/cli.mjs", "run", ...args], { + stdio: "pipe", + env: { ...process.env, KEYCHAIN_SKIP_ATTESTATION: "1" }, + }); + return null; + } catch (error: any) { + return { status: error.status, stderr: String(error.stderr) }; + } + }; + assert.match(attempt([identityFile, configFile, "echo"])!.stderr, /`--`/); + assert.match( + attempt([configFile, identityFile, "--", "echo"])!.stderr, + /not an agent identity/, + ); + const useOnly = attempt([identityFile, configFile, "--", "echo"])!; + assert.equal(useOnly.status, 1); + assert.match(useOnly.stderr, /no export-release secrets/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/lit-agent-keychain/web/src/main.tsx b/lit-agent-keychain/web/src/main.tsx index 55764f07..761951a1 100644 --- a/lit-agent-keychain/web/src/main.tsx +++ b/lit-agent-keychain/web/src/main.tsx @@ -842,6 +842,42 @@ function App() { )} + {isStored( + selected.manifest.document.manifest.release, + ) && ( +
+ How agents use this secret +

+ Hand the value to one command without ever + printing it. It reaches only that process's + environment, as{" "} + + {selected.envelope.document.metadata.name} + + . +

+
+                              
+                                {`keychain run ./agent-identity.json ./${selected.envelope.document.metadata.name}.keychain.json -- `}
+                              
+                            
+

+ For tools that read credentials from a path, add{" "} + + --file{" "} + {selected.envelope.document.metadata.name} + =PATH + {" "} + to write a private file that is removed when the + command exits. +

+

+ In code, keychain.get(name) returns + the value; the get_secret MCP tool + does the same for MCP clients. +

+
+ )} {!isStored( selected.manifest.document.manifest.release, ) &&