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
48 changes: 30 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { pay } from './commands/pay';
import { qr } from './commands/qr';
import { revoke } from './commands/revoke';
import { send } from './commands/send';
import { unlock } from './commands/unlock';
import {
walletAddress,
walletCreate,
Expand Down Expand Up @@ -204,6 +203,12 @@ export function buildCli() {
const wallet = Cli.create('wallet', { description: 'Manage the local encrypted keystore' });

wallet.command('create', {
// Hidden from MCP. Reachable on the CLI, where a human is present and the
// --danger / typed-confirm gates mean something. Served as an MCP tool those
// gates become boolean parameters the model can set for itself, so a single
// prompt injection reaches irreversible key disclosure or fund movement, and
// any secret returned lands in the transcript and the model provider's logs.
mcp: false,
description: 'Generate a new encrypted keystore. Omit --chain to create all three chains.',
options: z.object({
chain: chainSchema.optional().describe('Blockchain rail (base, solana, tempo)'),
Expand Down Expand Up @@ -268,6 +273,12 @@ export function buildCli() {
});

wallet.command('show-mnemonic', {
// Hidden from MCP. Reachable on the CLI, where a human is present and the
// --danger / typed-confirm gates mean something. Served as an MCP tool those
// gates become boolean parameters the model can set for itself, so a single
// prompt injection reaches irreversible key disclosure or fund movement, and
// any secret returned lands in the transcript and the model provider's logs.
mcp: false,
description: 'Print the stored BIP-39 mnemonic. DANGER — only run in a trusted environment.',
hint: 'The mnemonic restores every chain wallet — anyone with this phrase can drain your funds. Never paste it into chat, logs, or unencrypted storage.',
outputPolicy: 'agent-only',
Expand Down Expand Up @@ -298,6 +309,12 @@ export function buildCli() {
});

wallet.command('export', {
// Hidden from MCP. Reachable on the CLI, where a human is present and the
// --danger / typed-confirm gates mean something. Served as an MCP tool those
// gates become boolean parameters the model can set for itself, so a single
// prompt injection reaches irreversible key disclosure or fund movement, and
// any secret returned lands in the transcript and the model provider's logs.
mcp: false,
description: 'Decrypt and print a private key. DANGER — only run if you trust the surrounding environment.',
hint: 'The exported key gives full control of the wallet to anyone who reads it. Pipe to an encrypted store; never to a shared shell history.',
outputPolicy: 'agent-only',
Expand All @@ -320,6 +337,12 @@ export function buildCli() {
});

wallet.command('remove', {
// Hidden from MCP. Reachable on the CLI, where a human is present and the
// --danger / typed-confirm gates mean something. Served as an MCP tool those
// gates become boolean parameters the model can set for itself, so a single
// prompt injection reaches irreversible key disclosure or fund movement, and
// any secret returned lands in the transcript and the model provider's logs.
mcp: false,
description: 'Delete a keystore. DANGER — irrecoverable unless you have the BIP-39 mnemonic backup.',
hint: 'No undo. Run `wallet show-mnemonic --danger` first if you want to be able to restore.',
options: z.object({
Expand Down Expand Up @@ -493,6 +516,12 @@ export function buildCli() {

// ── send ────────────────────────────────────────────────────────────────────
cli.command('send', {
// Hidden from MCP. Reachable on the CLI, where a human is present and the
// --danger / typed-confirm gates mean something. Served as an MCP tool those
// gates become boolean parameters the model can set for itself, so a single
// prompt injection reaches irreversible key disclosure or fund movement, and
// any secret returned lands in the transcript and the model provider's logs.
mcp: false,
description: 'Raw transfer to an arbitrary address on Base, Tempo, or Solana. Default --asset usdc; --asset native sends gas (ETH on Base, TEMPO on Tempo, SOL on Solana). No merchant, no 402 handshake — just on-chain.',
hint: 'Both flavors require native gas in the signer wallet (gas pays the on-chain write, regardless of which asset is being transferred). x402/MPP payments are gasless; raw transfers are not.',
options: z.object({
Expand Down Expand Up @@ -551,23 +580,6 @@ export function buildCli() {
},
});

// ── unlock ──────────────────────────────────────────────────────────────────
cli.command('unlock', {
description: 'Cache the wallet passphrase to ~/.agentscore/.unlock for a bounded duration',
hint: 'Prefer AGENTSCORE_PAY_PASSPHRASE in env when running unattended — it leaves no on-disk artifact.',
options: z.object({
for: z.string().default('15m').describe('TTL — e.g. 15m, 2h, 30s, 1d (max 8h)'),
clear: z.boolean().optional().describe('Remove the cached passphrase'),
}),
examples: [
{ options: { for: '1h' }, description: 'Cache the passphrase for one hour' },
{ options: { clear: true }, description: 'Wipe the cached passphrase early' },
],
run({ options }) {
return withCliErrors(() => unlock({ forDuration: options.for, clear: options.clear }));
},
});

// ── discover ────────────────────────────────────────────────────────────────
cli.command('discover', {
description:
Expand Down
22 changes: 22 additions & 0 deletions src/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as solanaChain from '../chains/solana';
import * as tempoChain from '../chains/tempo';
import { type Chain, type Network } from '../constants';
import { CliError } from '../errors';
import { enforce, loadLimits } from '../limits';
import { DEFAULT_WALLET_NAME } from '../paths';
import { promptPassphrase } from '../prompts';
import { loadWallet } from '../wallets';
Expand Down Expand Up @@ -100,6 +101,27 @@ export async function send(input: SendInput): Promise<SendResult> {
throw new CliError('invalid_amount', '--amount must be a positive number.');
}

// Spend limits apply to EVERY transfer path, not just the 402 ones. `pay`
// enforced them and this did not, so a configured ceiling could be walked
// straight past by using `send` instead. Only the USDC path is denominated in
// dollars, so that is the one a USD limit can judge; a native transfer moves
// gas tokens whose USD value this command does not price, and inventing a
// conversion here would be a worse guess than not claiming one.
//
// A no-op when nothing is configured: enforce() returns allowed with no
// limits set, so this changes behavior only for someone who asked for it.
if (asset === 'usdc') {
const limits = await loadLimits();
const verdict = await enforce(limits, { priceUsd: input.amount, host: `send:${input.chain}` });
if (!verdict.allowed) {
throw new CliError(
'limit_exceeded',
`Local limit violated: ${verdict.violated}=${verdict.limit}`,
{ extra: { violated: verdict.violated, limit: verdict.limit, would_be: verdict.would_be } },
);
}
}

const passphrase = await promptPassphrase();
const wallet = await loadWallet(input.chain, passphrase, input.name ?? DEFAULT_WALLET_NAME);

Expand Down
28 changes: 0 additions & 28 deletions src/commands/unlock.ts

This file was deleted.

15 changes: 11 additions & 4 deletions src/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cancel, isCancel, password as clackPassword } from '@clack/prompts';
import { CliError } from './errors';
import { readCachedPassphrase } from './unlock-cache';
import { clearCache } from './unlock-cache';

const ENV_PASSPHRASE = 'AGENTSCORE_PAY_PASSPHRASE';

Expand All @@ -11,13 +11,20 @@ function isInteractive(): boolean {
export async function promptPassphrase(message = 'Enter wallet passphrase'): Promise<string> {
const envPass = process.env[ENV_PASSPHRASE];
if (envPass) return envPass;
const cached = await readCachedPassphrase();
if (cached) return cached;

// The plaintext passphrase cache is gone; this DELETES a leftover one rather
// than reading it. An installation upgrading from a version that wrote the
// file would otherwise keep a cleartext secret on disk with nothing left that
// admits to using it, which is worse than either using it or never having
// written it. Silent because it is cleanup rather than an error, and the
// passphrase is asked for immediately below.
await clearCache().catch(() => false);

if (!isInteractive()) {
throw new CliError('user_cancelled', 'Passphrase required but no TTY and AGENTSCORE_PAY_PASSPHRASE not set.', {
nextSteps: {
action: 'set_env_passphrase',
suggestion: 'Set AGENTSCORE_PAY_PASSPHRASE=... in the environment, or run `unlock --for 15m` from a TTY first.',
suggestion: 'Set AGENTSCORE_PAY_PASSPHRASE=... in the environment. The `unlock` passphrase cache was removed: it stored the passphrase in cleartext on disk, and the environment variable is the supported way to run unattended.',
},
});
}
Expand Down
85 changes: 26 additions & 59 deletions src/unlock-cache.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,38 @@
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { CliError } from './errors';
import { rm } from 'fs/promises';
import { join } from 'path';
import { baseDir } from './paths';

interface UnlockCache {
passphrase: string;
expires_at: string;
}

const MAX_TTL_MS = 8 * 60 * 60 * 1000;

export function unlockCachePath(): string {
/**
* Removal of the plaintext passphrase cache.
*
* `~/.agentscore/.unlock` used to hold the wallet passphrase in cleartext for up
* to 8 hours, beside the scrypt+AES keystores it unlocks. File modes (0600 in a
* 0700 dir) were the only thing protecting it, which does nothing against
* anything already running as that user: malware, a backup, a CI cache, or an
* LLM agent with filesystem tools.
*
* The write path and the read path are both gone. What is left is the purge,
* for two reasons: `wallet remove` still wants to wipe any cached secret when it
* deletes a keystore, and an installation that HAS an `.unlock` from an older
* version needs it deleted rather than orphaned. Leaving the file readable but
* ignored would be the worst outcome, since the secret would still be on disk
* with nothing left that admits to using it.
*
* Unattended use is `AGENTSCORE_PAY_PASSPHRASE` in the environment, which the
* CLI already recommended over this cache and which leaves no on-disk artifact.
*/

function unlockCachePath(): string {
return join(baseDir(), '.unlock');
}

export async function readCachedPassphrase(): Promise<string | null> {
try {
const raw = await readFile(unlockCachePath(), 'utf-8');
const cache = JSON.parse(raw) as UnlockCache;
const expires = Date.parse(cache.expires_at);
if (!Number.isFinite(expires) || Date.now() > expires) {
await clearCache();
return null;
}
return cache.passphrase;
} catch {
return null;
}
}

export async function writeCachedPassphrase(passphrase: string, ttlMs: number): Promise<string> {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new CliError('invalid_input', 'unlock duration must be positive');
}
const clamped = Math.min(ttlMs, MAX_TTL_MS);
const path = unlockCachePath();
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const expires = new Date(Date.now() + clamped).toISOString();
const cache: UnlockCache = { passphrase, expires_at: expires };
await writeFile(path, JSON.stringify(cache), { mode: 0o600 });
return expires;
}

/** Delete the cache if present. Returns whether a file was actually removed. */
export async function clearCache(): Promise<boolean> {
try {
await rm(unlockCachePath());
return true;
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && (err as { code: string }).code === 'ENOENT') return false;
throw err;
}
}

const DURATION_PATTERN = /^(\d+)([smhd])$/;

export function parseDuration(input: string): number {
const m = DURATION_PATTERN.exec(input.trim());
if (!m) {
throw new CliError('invalid_input', `Invalid duration "${input}" — use e.g. 15m, 2h, 30s, 1d.`);
}
const n = Number(m[1]);
const unit = m[2];
switch (unit) {
case 's': return n * 1000;
case 'm': return n * 60 * 1000;
case 'h': return n * 60 * 60 * 1000;
case 'd': return n * 24 * 60 * 60 * 1000;
default: throw new CliError('invalid_input', `Unknown duration unit: ${unit}`);
if (err && typeof err === 'object' && 'code' in err && (err as { code: string }).code === 'ENOENT') { return false; }
return false;
}
}
7 changes: 5 additions & 2 deletions tests/error-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ describe('compact error envelope — JSON', () => {
});

it('omits extra and next_steps fields when CliError has neither', async () => {
// unlock --for with bad format → invalid_input, no extras, no nextSteps
const { json, exitCode } = await runJson('unlock', '--for', 'bad-format');
// A bare invalid_input with no extras and no nextSteps. This used
// `unlock --for bad-format` until the plaintext passphrase cache and its
// command were removed; `assess` with neither identity raises the same
// shape (identity.ts throws CliError('invalid_input', msg) with no options).
const { json, exitCode } = await runJson('assess');
expect(exitCode).toBe(1);
expect(json.code).toBe('invalid_input');
expect(json.extra).toBeUndefined();
Expand Down
Loading