Skip to content

fix(cli): close wallet-security gaps, bump CLI to 0.5.0 - #60

Merged
Shawnchee merged 2 commits into
mainfrom
fix/cli-wallet-security-0.5.0
Aug 25, 2026
Merged

Shawnchee merged 2 commits into
mainfrom
fix/cli-wallet-security-0.5.0

Conversation

@Shawnchee

Copy link
Copy Markdown
Collaborator

Triaged the CLI's wallet handling and fixed the three issues that could destroy or over-expose a user's key. All three were unsafe defaults, so making them explicit is a breaking change — hence 0.5.0 rather than a patch.

Scope is cli/ only. The SDK and mcp-server are untouched.

Fixes

Atomic config writes (cli/src/config.ts)
saveConfig wrote in place, so a crash, SIGINT, or ENOSPC could truncate config.json. wallet create discards the BIP-39 mnemonic and tells the user that file is their only copy of the key — a torn write there permanently destroyed funds. Now writes a temp file and rename(2)s it, the same pattern already used by the RFQ key storage.

Intentional side effect: a symlink at the config path is now replaced rather than written through, closing the write-side gap left by loadConfig's O_NOFOLLOW read. Users who symlink their config from a dotfiles repo will find the link severed.

No more silent unlimited approvals (cli/src/commands/wallet.ts, cli/src/commands/rfq.ts)
wallet approve --amount defaulted to max, so wallet approve --token USDC --for optionBook --yes granted MaxUint256 with no interactive gate — the warning went to stderr and --yes auto-passed the confirm. --amount is now required.

rfq request --ensure-allowance now approves exactly the escrowed reservePrice on a BUY, matching book fill and position close.

SHORT RFQs deliberately keep the max default. The settle-time collateral draw is a structure-dependent max-loss figure not carried on the request — params.collateralAmount is hardcoded to 0 by every SDK builder and the send path rejects any nonzero value. Under-approving a SHORT reverts at settlement after a maker has committed, which is worse than a broad allowance. --approve-amount <n> remains available to cap it.

--yes is not consent to destroy a key (cli/src/commands/wallet.ts)
wallet create and wallet import now exit 2 when the config already holds a key. The old key has no other copy on disk and the replacement's mnemonic is not persisted, so a habitual --yes in a script silently burned funds with only a stderr line to show for it. wallet create --force overwrites deliberately. Matches the existing rule that --reveal-key refuses --yes.

Tests

Adds cli/tests/approvalDefaults.test.ts — the approval-target defaults and saveConfig atomicity/permissions/symlink behavior.

Worth flagging: an earlier attempt at the SHORT default used params.collateralAmount, which is always 0. That made --ensure-allowance a silent no-op ending in a settlement revert — and it passed all 14 existing tests, because none of them touched these paths. The new test asserts that a zero target is the bug.

Breaking changes

Change Migration
wallet approve requires --amount Add --amount <n>, or --amount max for the old behavior
wallet create/import --yes over an existing key Use wallet create --force, or drop --yes and confirm interactively
BUY rfq request --ensure-allowance no longer leaves an unlimited allowance Pass --approve-amount max if a later tx relied on the leftover

Known gaps — not addressed here

  • The private key is still stored unencrypted, protected only by 0600 perms. That stops other users on the machine, not another process running as you. An encrypted keystore / OS-keychain option is a separate design decision.
  • config set privateKey and config unset privateKey still overwrite or delete the key with no confirmation.

Both are documented in the CHANGELOG rather than left to be discovered.

Verification

  • npx tsc --noEmit clean
  • npm test — 15/15
  • npm run build clean; dist/index.js --version → 0.5.0
  • npm pack --dry-run resolves
  • Manual: both gates refuse --yes with exit 2 and leave the key intact on disk; --force still overwrites; atomic write verified for perms (600/700), no temp leftovers, and symlink target untouched

Not run: prepublishOnly's swap-sdk-dep.cjs. Worth confirming that resolves to an SDK version compatible with these changes before npm publish.

Three defaults could destroy or over-expose a user's key. All three are now
explicit choices, which makes this a breaking release.

Atomic config writes. saveConfig wrote in place, so a crash, SIGINT, or ENOSPC
could truncate config.json. Because `wallet create` discards the BIP-39
mnemonic and declares that file the only copy of the key, a torn write there
permanently destroyed funds. It now writes a temp file and renames it, matching
the RFQ key storage. Side effect, intentional: a symlink at the config path is
replaced rather than written through, closing the write-side gap left by
loadConfig's O_NOFOLLOW read.

No more silent unlimited approvals. `wallet approve --amount` defaulted to max,
so a bare invocation with --yes granted MaxUint256 with no interactive gate.
It is now required. `rfq request --ensure-allowance` approves exactly the
escrowed reservePrice on a BUY, matching book fill and position close.

SHORT RFQs deliberately keep the max default. The settle-time collateral draw
is a structure-dependent max-loss figure that is not carried on the request --
params.collateralAmount is hardcoded to 0 by every SDK builder and the send
path rejects any nonzero value. Under-approving a SHORT reverts at settlement
after a maker has committed, which is worse than a broad allowance.

--yes is no longer consent to destroy a key. `wallet create` and `wallet
import` exit 2 when the config already holds a key, instead of overwriting it.
The old key has no other copy and the replacement's mnemonic is not persisted,
so a habitual --yes in a script silently burned funds. Use --force to overwrite
deliberately. This matches the existing rule that --reveal-key refuses --yes.

Adds tests/approvalDefaults.test.ts. No existing test touched these paths, and
an earlier attempt at the SHORT default -- using params.collateralAmount, which
is always 0 -- turned --ensure-allowance into a silent no-op and passed the
whole suite. The regression test asserts a zero target is the bug.

Known gaps, documented in the CHANGELOG: the private key is still stored
unencrypted under 0600 perms, and `config set/unset privateKey` still overwrite
or delete it with no confirmation.

BREAKING CHANGE: `wallet approve` requires --amount; `wallet create/import`
reject --yes over an existing key; BUY `rfq request --ensure-allowance` no
longer leaves an unlimited allowance behind. Migration steps in the CHANGELOG.
Two follow-ups to the 0.5.0 wallet-security work, both consequences of it.

Exit codes. Making `wallet approve --amount` required surfaced a contract
violation: Commander exits 1 for every parse failure, but the README documents
1 as a generic runtime error (network, RPC, contract revert) and 2 as a usage
error. Automation could not tell a mistyped flag from a reverted transaction.
Commander's usage-error codes are now mapped to 2 across the whole command
tree -- exitOverride is per-Command and is not inherited, so the walk has to be
recursive to reach grandchildren like `wallet approve`.

The mapping also covers a group command invoked with no subcommand
(`thetanuts wallet`, or bare `thetanuts`), which Commander reports as
commander.help with exitCode 1. That is an incomplete invocation, not a runtime
failure. Successful --help is a distinct code (commander.helpDisplayed,
exitCode 0) and still exits 0, as does --version. Codes the map does not
recognise keep Commander's own exit code rather than being coerced, so a new
code in a future Commander release fails visibly instead of masquerading as a
usage error. Runtime paths that exit 1/3/4/6 from inside action handlers never
reach exitOverride and are unaffected.

CI. The workflow installed, built, and tested only the root SDK, so every CLI
test could fail without blocking a merge -- including the regression test added
last commit to catch a silent no-op in the RFQ allowance path. Adds a `cli` job
and makes all-checks depend on it.

The job builds the root SDK before touching the CLI. The CLI resolves the SDK
through `file:..` and the SDK's package entry points at a gitignored dist/, so
a fresh checkout has nothing to compile against; without that step the job
fails on unresolved types. Verified by running the full sequence in a clean
clone.

Adds tests/exitCodes.test.ts. Exit status is a process-level contract, so the
assertions spawn the CLI as a subprocess -- an in-process call cannot observe
it. Covers both directions of the help/helpDisplayed split, which is the pair
most likely to regress together.
@Shawnchee
Shawnchee merged commit 9e72aef into main Aug 25, 2026
7 checks passed
@Shawnchee
Shawnchee deleted the fix/cli-wallet-security-0.5.0 branch August 25, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant