Skip to content

feat(wallet): key custody through an external secret manager - #33

Merged
nijoe1 merged 14 commits into
mainfrom
feat/vault-key-ref
Aug 5, 2026
Merged

feat(wallet): key custody through an external secret manager#33
nijoe1 merged 14 commits into
mainfrom
feat/vault-key-ref

Conversation

@nijoe1

@nijoe1 nijoe1 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Adds a third custody mode alongside a raw private key and a Foundry keystore: the config holds a <provider>:<reference> pointer, and the key is fetched into memory per command.

foc-cli wallet init --keyRef clawdi:FILECOIN_PRIVATE_KEY
foc-cli upload ./blob.json        # ordinary usage from here — no wrapper, no prefix

Why

references/keystore-setup.md already documents a gap it cannot close: keystore mode prompts for its password on a tty at use time, so under MCP or CI it simply fails, and the only remaining option is a raw key sitting in the config file. That is the mode agents run in.

A key reference prompts for nothing. It is the first custody mode that works under automation with no key at rest — and after wallet init, every command behaves exactly as it does with a raw key or a keystore.

Shape

This is not a new concept in the CLI. The keystore path already names an external source in config and runs a helper at use time to produce the key on stdout; this is a second instance of that pattern, with clawdi vault resolve in place of cast wallet decrypt-keystore. foc-cli already hardcodes one vendor's binary (Foundry's cast), so a second named provider is not a new kind of coupling.

Providers are a closed set: the executable is chosen by code and only the reference comes from config, so a tampered config cannot turn key resolution into arbitrary command execution. That is deliberately not a general "run this command to get my key" field.

What is in here

Key reference modewallet init --keyRef <provider>:<reference>, optional --keyProject (omitted, the provider picks its own default). clawdi is the first provider. Resolution stays lazy, so read-only commands (docs, provider list) never pay for it.

wallet init no longer silently replaces a configured wallet. This is a behaviour change and the reason for a minor bump. An explicit method used to overwrite whatever was configured, discarding a key that may have been the only copy. It now names what would be lost — the derived address for a private key, the path for a keystore, the reference for a key reference, never the key itself — and asks on a terminal, or fails with WALLET_ALREADY_CONFIGURED plus a --force CTA in agent mode. Re-running the same method with the same value replaces nothing and is never blocked; confirming idempotent calls would only train agents to pass --force reflexively.

Guidance never names a tool you do not have. Key-reference CTAs appear only when that provider's CLI is actually on PATH — a stat, no process, no network. The reference docs still describe every provider; a CTA describes what works on this machine.

Typed preflight. Every wallet-touching command checks the cheap things before constructing a client, so WALLET_NOT_CONFIGURED and KEY_REF_PROVIDER_MISSING arrive as typed errors with next steps instead of escaping as an untyped throw from inside key resolution. The preflight deliberately does not resolve the key: that needs an authenticated provider and a round trip, and belongs at use time.

wallet balance reports keySource. The address proves which key signed; this proves where it came from. Never the value.

Deliberate choices worth reviewing

  • Configuring a reference before installing its provider succeeds, returning providerAvailable: false with a warning, rather than failing. Image layers and provisioning scripts run in an order you do not control, and nothing is at risk until a command signs. The hard failure stays where it is actionable.
  • Errors never echo what was resolved. A reference pointing at the wrong field must not print that field's contents; there is a test asserting the message excludes the resolved value.
  • Windows. execFileSync does not apply PATHEXT, and an npm-installed helper is clawdi.cmd there. Candidates are probed and stated rather than reaching for shell: true, which would put a config-supplied string through a shell.
  • No cross-command cache. Each wallet command costs one resolver call. A cache would be a key at rest.

Compatibility

keyRef is absent on every existing install, so the branches below it are reached unchanged and there is nothing to migrate. The one caller-visible change is the wallet init guard: automation that re-runs wallet init --auto expecting a fresh key must now pass --force.

Verification

bun test 127 pass (18 new), biome check clean, tsc --noEmit clean, bun run build clean.

Exercised end to end against a real clawdi and against a stub provider: correct address derived from the resolved key, keySource: keyRef, CTA gating confirmed with the provider on and off PATH, the overwrite guard refusing and then accepting --force, and an idempotent re-set passing through untouched.

Not yet measured: clawdi vault resolve latency against the live service in a long MCP session.

nijoe1 added 14 commits August 5, 2026 15:30
Adds a third custody mode alongside a raw key and a Foundry keystore: the config holds a <provider>:<reference> pointer and the key is fetched into memory per command, so nothing is ever at rest.

This is the same shape the keystore path already uses -- config names an external source, and a helper is run at use time to produce the key on stdout -- so it introduces no new concept, only a second instance of an existing one.

It closes the gap keystore-setup.md documents: keystore mode prompts on a tty, so MCP and CI have had no custody option but a key in the config file. A key reference prompts for nothing, which makes it the first mode that works under automation with no key on disk. After wallet init --keyRef, every command is ordinary foc-cli usage -- no wrapper, no prefix, no environment to prepare.

Design notes:

Providers are a closed set. The executable is chosen by code and only the reference comes from config, so a tampered config cannot turn key resolution into arbitrary command execution -- the property the keystore path has, and the reason there is no general "run this to get my key" field.
keyRef is absent on every existing install, so the branches below it are reached unchanged and there is nothing to migrate.
Only one custody mode is ever live: setting any of them clears the others. Without that, a stale privateKey would sit at rest where nothing reads it.
execFileSync does not apply PATHEXT on Windows, where an npm-installed helper is clawdi.cmd. Candidates are probed and stat'd rather than reaching for shell: true, which would put a config-supplied string through a shell.
Errors never echo what was resolved: a reference pointing at the wrong field must not print that field's contents.
Resolution stays lazy, so read-only commands never pay for it.
wallet balance now reports keySource, so a vault-backed setup is verifiable at a glance -- the address proves which key signed, this proves where it came from.
…fering them

Three related gaps around the key-reference mode, all of them about not
misleading or surprising the caller.

Never suggest a tool the machine does not have. `wallet init` guidance offered
`--keyRef clawdi:...` unconditionally, which on a machine without clawdi is a
dead end an agent will walk into. Providers are now probed on PATH -- a stat,
no process, no network -- and only offered where they would work. The reference
docs still describe every provider; a call to action describes what you can do
right now.

Refuse to discard a configured key. An explicit method replaced whatever was
configured, silently and unrecoverably. It now names what would be lost -- the
derived address for a private key, the path for a keystore, the reference for a
key reference, never the key itself -- and asks on a terminal, or fails with
WALLET_ALREADY_CONFIGURED and a --force call to action in agent mode. Only a
change that actually replaces something triggers it: re-running the same
reference is idempotent, and prompting for that would train agents to pass
--force reflexively, defeating the guard.

Check the cheap things first. Every wallet-touching command now runs a preflight
before constructing a client, so "no wallet" and "provider not installed" arrive
as WALLET_NOT_CONFIGURED / KEY_REF_PROVIDER_MISSING with actionable next steps,
instead of escaping as an untyped throw from inside key resolution. The
preflight deliberately does not resolve the key: that needs an authenticated
provider and a round trip, and belongs at use time.

Configuring a reference before installing its provider stays legal -- image
layers and provisioning scripts run in an order you do not control -- but
`wallet init` now reports providerAvailable and warns, so the next command
cannot fail confusingly.
Adds external key custody ([#33]) — a config-held reference to a key kept in a
secret manager, resolved per command. Contains one behaviour change, so the
next publish must not ship as a patch: wallet init no longer silently replaces
a configured wallet.

Atomic bump: package version, both skill frontmatters, version pins in skill
examples, and the changelog Unreleased retitle — CI pins them together.
Accept a resolved value only when it is a 0x + 64 hex token standing on
its own, and refuse output holding more than one. Any 32 bytes form a
valid secp256k1 key, so the old unanchored first-match took the leading
half of a longer blob and signed as a different address instead of
failing.

Run .cmd/.bat helpers through cmd.exe. npm installs clawdi.cmd on
Windows, which CreateProcess cannot launch and Node has refused to since
the fix for CVE-2024-27980: the PATH probe found it, the launch failed
with EINVAL, and that was reported as "not logged in / wrong project".
References and project scopes are restricted to characters a shell
treats literally, so a tampered config still cannot become command
execution.

Cache PATH hits so a signing command probes once rather than twice, and
expose the probe and the key-ref call to action for reuse.
The WALLET_ALREADY_CONFIGURED call to action echoed the caller's whole
option set, so a --privateKey passed on the refused invocation came back
inside the error envelope — into the MCP result, the agent's context and
every log downstream. Replay an allowlist instead, with the key as 0x...

Adding or changing --keyProject on a configured reference re-scopes the
same lookup and replaces nothing, but was refused as destructive; it now
passes through. State the consequence that actually applies, too: only a
stored private key is destroyed by a swap, while a keystore file stays
on disk and a vault key stays in the vault.

Update the description and MCP title, which still promised that an
explicit method replaces any configured wallet with no mention of
--force, and take the key-ref call to action from key-ref.ts rather than
from a second copy of it.
The preflight covered the key-reference mode only, so a keystore install
still died inside cast as an untyped throw from outside the command's
try block — the shape the guard exists to remove. It now also reports
KEYSTORE_INTERACTIVE_ONLY under an agent and KEYSTORE_TOOL_MISSING when
Foundry is unreachable, plus MALFORMED_KEY_REF for a reference with no
provider prefix, which previously escaped as UNKNOWN with no code and no
way to act on it.

KEY_REF_PROVIDER_MISSING no longer suggests `wallet init --auto --force`.
A missing provider is usually a short PATH under an agent, and agents
follow calls to action: the only one offered would have replaced a
funded vault-backed wallet with a throwaway testnet key. It now carries
no command and is marked retryable.

Collapse the fifteen pasted copies of the guard into requireWallet(),
and assert structurally that every command building a signing client
calls it first.
The identification table still told readers an unconfigured wallet
surfaces as "Private key not found", contradicting the error catalog
forty lines below it. Catalog the new codes, say that a missing provider
is retryable and must not be "fixed" by re-initializing, and note that
--keyProject on a configured reference needs no --force.
Six findings from review of this branch, all behavioural — the build and
the suite were already clean, which is the point: none of these announce
themselves.

Silent wrong-key hazards:

- `wallet init --keyRef X` on a wallet already pinned to `--keyProject`
  dropped the pin. The force guard correctly reads an identical reference
  as a no-op and never asks, so the delete ran unconfirmed and the next
  command re-resolved against the provider's default project — a
  different key, a different signing address, nothing on screen. The
  scope is now cleared only when it stops applying: an explicit
  `--keyProject ""`, or a reference that actually changed.

- A configured keystore was invisible to the already-configured checks,
  so bare `wallet init` fell past them to the interactive prompt, which
  writes a private key and deletes the keystore. `--force` bypassed
  entirely. It now reports `already_configured` like the other two modes.

- The keystore key scrape kept an unbounded `search()` + `slice(+66)`
  while the key-reference path in the same branch refused exactly that
  shape. Every 32-byte value is a valid secp256k1 key, so the first 64
  hex digits of a longer blob would have signed as a different address.
  Both paths now share `findPrivateKeys`, and the ambiguous case is
  refused rather than guessed.

- `--keyRef 0x<64 hex>` — plausible, since it sits beside `--privateKey`
  in help — was echoed verbatim into `INVALID_KEY_REF` and replayed into
  the `WALLET_ALREADY_CONFIGURED` CTA, past the allowlist that exists to
  keep secrets out of envelopes. Redacted by shape now, in the message
  and in the replay, and the message names `--private-key` instead.

Setups that had stopped working, and errors that were not what they said:

- Keystore mode was gated on `isAgent()`, which is true whenever stdout
  is not a TTY — so `wallet balance --json | jq` and `wallet costs >
  costs.json` began refusing on installs where they had always worked.
  `cast` reads the password from /dev/tty; a pipe takes nothing away.
  New `canPrompt()` asks the question actually being asked.

- An unrecognized provider prefix reached `isProviderAvailable()`, which
  cannot tell "does not exist" from "not installed", and was reported as
  a retryable PATH gap with a null install hint. An agent would retry a
  permanent misconfiguration forever. Now `UNKNOWN_KEY_REF_PROVIDER`,
  not retryable, listing the providers that do exist.

Also narrows the keystore `try` to the `cast` call it diagnoses — the
scrape sat inside it, so "no key found in the output" was reported as
"Mac Mismatch means the password was wrong", which is the one thing it
is not.

Docs updated where they described the old behaviour.
Finding 6 and the validation trio.

**Key resolution failures are typed at the throw.**

`resolveKeyRef` and the keystore decrypt run at *use* time, from inside
the client construction every signing command performs before its try
block — so a plain Error reached incur's top-level handler and rendered
as `{ code: 'UNKNOWN' }`, no code and no `retryable`. That is what an
agent saw for the most common failure a vault-backed wallet has:
installed, but not logged in.

The obvious fix — move construction inside each command's try — would
have made it worse. Those catches end in `out.fail('UPLOAD_FAILED', …)`,
`'COSTS_FAILED'`, `'DATASET_LIST_FAILED'`, so a key that could not be
fetched would be reported as an upload that failed: typed, and wrong.
Throwing `Errors.IncurError` instead fixes all 15 commands at once,
plus any command that does not exist yet, and needs no command edits.

Codes are shared with the ones `walletPreflight` already emits for the
same conditions — whether the guard caught it or the resolver did is an
implementation detail, and the fix is identical either way. New at use
time: `KEY_REF_RESOLUTION_FAILED` (provider ran and refused — explicitly
not retryable, since every cause needs a deliberate act),
`KEY_REF_NOT_A_KEY`, `KEY_REF_AMBIGUOUS`, `KEYSTORE_DECRYPT_FAILED`,
`KEYSTORE_NOT_A_KEY`, `KEYSTORE_AMBIGUOUS`.

**A leading `-` is no longer a legal reference.**

`clawdi:--project` passed SAFE_REF and reached argv as `clawdi vault
resolve --project`, so config steered the helper's own option parsing
rather than naming a secret. Not arbitrary execution, but more than
"only the reference comes from config" allows. A dash anywhere else
stays legal — `FILECOIN-PRIVATE-KEY` still resolves.

**`wallet init` refuses what no command could resolve.**

It validated the `<provider>:<ref>` shape and nothing else, so
`clawdi:MY KEY&touch x` returned `configured`, cleared the previous
wallet, and left every later command failing with "re-run `foc-cli
wallet init`" — pointing back at the command that had just accepted it.
The check is shared with the resolver via `unsafeRefReason`, and the
preflight applies it too so a config already holding one gets a CTA
instead of a bare throw mid-command.

**`--keyProject` on its own now does what both docs promise.**

No branch consumed it: the command fell through to `already_configured`,
wrote nothing, and reported success, so the caller believed a scope was
pinned that never was and every command kept resolving against the
provider's default project. It now re-scopes the configured reference.
Without one it fails `KEY_PROJECT_WITHOUT_KEY_REF`, and combined with
`--auto`/`--privateKey`/`--keystore` it is refused rather than shadowing
them — answering a contradiction by quietly picking one is the same
silent-ignore this removes.
incur renders an example option whose value is `true` as `--withCDN true`,
and the parser never reads a boolean's value from the next token — so the
flag is enabled and `true` is left over as a stray positional. Nine
examples taught that form, and `--prompt upload files` searched for
"upload" while silently dropping "files".

Drop the boolean options from examples, which cannot express a bare
switch, and state the correct syntax in each command's hint instead.
Every CTA offering a switch was unrunnable: incur renders `{ force: true }`
as `--force <force>`, a placeholder a shell reads as a redirect, so the
escape hatch for the whole --force guard could not be run. Switches now
travel in the command string via ctaFlags, and Problem.cta is typed CTA
rather than any — the missing type is how this shipped.

Both external key tools ran execFileSync with no timeout. They are
synchronous, so a hung vault call or a password prompt with nobody to
answer it froze the event loop, and with it the MCP server. Both are now
bounded and report typed retryable failures.

Also: redact a key-shaped keyRef before quoting it into a refusal;
validate the whole init request before writing anything, so a refused
init leaves config untouched and a keystore that cannot work here is
refused on the first call; refuse conflicting custody methods instead of
letting --keyRef win silently; type a malformed stored private key in the
preflight; treat an empty key project as absent, matching the argv
builder; fold dropped IncurError hints into the message, which is the
only field the envelope carries.
Checked every documented command, flag, and error code against the built
CLI's help and --schema output. Fixes: --chain and --debug were claimed
as global but four commands define neither; `piece list` was shown without
its required <dataSetId>; download's --force and refusal to overwrite,
wallet costs' --withCDN, and wallet init's --keyProject and --force were
undocumented; MCP tools were described as underscore-separated, but
`multi-upload` keeps its hyphen, so an agent generalising the rule calls
multi_upload and fails.

Both skills also had the boolean rule backwards: a value is never read
from the next token, so `--flag false` enables the flag. Only
`--flag=false` disables it.

Documents the codes added alongside: CONFLICTING_INIT_METHODS,
KEY_REF_TIMED_OUT, KEYSTORE_TIMED_OUT.
Every drift fixed in the previous commits was only findable by running the
example, which is the moment it costs the most. This checks each one
against the imported Zod schemas — unknown flags, missing required args,
booleans passed a value, extra positionals — so it needs no build and
fails on the commit that introduces the drift.

Also asserts COMMANDS covers every command module, since a new command
nobody registers would be silently exempt.
A key is pure hex, so `--key-ref clawdi:0x<key>` passed the character
allowlist: the key was stored under a field documented as safe to
display, sent to the provider as a lookup name, and quoted verbatim in
the use-time error envelope bound for the MCP result and the logs.

Validation now refuses a value that is itself a private key — init and
the preflight both, via the shared rule — and every surface that quotes
a reference (resolver failures, the unknown-provider message, init's
own results) redacts key-like runs first, a no-op for every legitimate
reference.
@nijoe1
nijoe1 merged commit 383aa6a into main Aug 5, 2026
1 check passed
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