Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lit-agent-keychain/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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
Expand Down
12 changes: 10 additions & 2 deletions lit-agent-keychain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>` 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`:
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions lit-agent-keychain/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions lit-agent-keychain/sdk/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import {
Keychain,
ACTIONS,
Expand All @@ -13,13 +14,17 @@ const usage =
" keychain init <identity-file>\n" +
" keychain get <identity-file> <config-file> <secret-name>\n" +
" keychain use <identity-file> <config-file> <secret-name> [json-input]\n" +
" keychain run <identity-file> <config-file> [--only A,B] [--env SECRET=ENV_VAR]... [--file SECRET=PATH]... -- <command> [args...]\n" +
" keychain actions\n" +
" keychain mcp <identity-file> <config-file> [more-config-files]\n" +
" keychain attest [lit-api-url]\n" +
"\n" +
"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) =>
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions lit-agent-keychain/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"cli.mjs",
"mcp.mjs",
"mcp.d.mts",
"run.mjs",
"run.d.mts",
"tls.mjs",
"README.md"
],
Expand Down
51 changes: 51 additions & 0 deletions lit-agent-keychain/sdk/run.d.mts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** Secret name to file path; such secrets stay out of the environment unless also renamed. */
files: Record<string, string>;
/** 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<RunOptions, "only" | "rename"> &
Partial<Pick<RunOptions, "files">>,
): InjectionPlan;

export function runWithSecrets(
client: {
list(): { name: string; operation: string }[];
get(name: string): Promise<string>;
destroy(): void;
},
options: Pick<RunOptions, "only" | "rename" | "files" | "command">,
io: {
spawn: (file: string, args: string[], options: SpawnOptions) => ChildLike;
env: NodeJS.ProcessEnv | Record<string, string>;
stderr: { write(chunk: string): unknown };
process: {
on(signal: string, handler: () => void): unknown;
off(signal: string, handler: () => void): unknown;
};
},
): Promise<number>;
Loading
Loading