About this book
The signer’s docs in one place: what it checks, how to install and run it, its configuration, its threat model, the source layout, and the plan. It is published at https://ttdonovan.github.io/sa-forge-signer/, built from main, so it can describe changes not yet released; the docs for a release are in that tag’s docs/src/.
Most chapters are written here, in docs/src/. A few include a file from where it lives in the repository, so the book and that file are the same text; change the file, not the chapter: Security (SECURITY.md), Contributing (CONTRIBUTING.md), Plan (PLAN.md), Changelog (CHANGELOG.md), Writing the docs (docs/CLAUDE.md), Building an app (apps/CLAUDE.md), Local review (scripts/git-review/README.md), and the example config in Configuration (config.example.toml). The repository’s README.md is a short front page that links here; it is not a chapter.
Building it
The book is built with mdBook, mdbook-mermaid for the diagrams, and mdbook-llms. Install the tools once, from GitHub, with just docs setup:
cargo install --locked mdbook --version 0.5.2
cargo install --locked mdbook-mermaid --version 0.17.1
cargo install --locked --git https://github.com/ttdonovan/mdbook-llms --tag v0.4.0
Then build from the repository root with just docs, or just docs serve to read it in a browser. The book is written to target/docs/html/, which is not committed.
Agents: read target/docs/html/llms.txt first (or the published one). It lists every chapter with a line on what it holds and its size; then read only the chapters a task needs (each .md beside it), or llms-full.txt for the whole book at once. Its preamble is src/llms-preamble.md.
A new doc gets a chapter here and a line in src/SUMMARY.md: frontmatter with a description (one line for the index), then the text, or an {{#include}} of its source. just docs check fails on a build error, an empty chapter, or a link that leads nowhere.
How it works
The signer is deliberately thin: it does not decode game instructions and needs no IDL. The chain enforces what a profile key may do (scope, permission mask, expiry); the signer checks what the chain does not:
- Chain: the RPC’s genesis hash matches Z.ink testnet, so a wrong RPC URL cannot redirect signatures. A process reads it once and reuses it for 5 minutes, and keeps its RPC connections open between signs (RPC calls and latency).
- Fee payer: the fee payer is the signing key.
- Programs: every top-level instruction calls SAGE, Player Profile, Profile Faction, ComputeBudget, Associated Token or System (plus per-key extras).
- Tokens: SPL Token and Token-2022 are never called at the top level, even if a key lists them as extras (token movement in play happens inside SAGE). Associated Token may only create an account, naming the real System and SPL Token or Token-2022 programs (the ATA program makes whatever token program it is given the new account’s owner), and only for the signing key, one of its transfer destinations, or an existing account owned by one of the cluster’s built-in game programs (a character or cargo cache). A key’s
extra_programsmay be called, but never count as game programs for this rule. Creating a token account pays its rent to the owner, so an arbitrary owner would be a way to move lamports out. - Compute budget: only
SetComputeUnitLimitup to the key’smax_compute_unitsandSetComputeUnitPriceup to itsmax_compute_unit_price(default 0, so no priority fee), each at most once and with no accounts. Every other ComputeBudget instruction is refused (compute_budget), so one payload cannot spend the daily cap as a priority fee. - System Program: only
Transfer, from the signing key, and only to per-key allowed destinations, with all of a transaction’s transfers together within the key’stransfer_max_lamportsif it sets one. No account creation, assignment or allocation at the top level (forge-mcp creates new accounts inside the game programs). - Signers: every required signer is the signing key or a payload partial signer, and partial signers do not exist on-chain yet.
- Simulation must succeed before anything is sent. The node simulates an unsigned copy of the message (
sigVerify: false); insignit keeps the message’s own blockhash, so an expired one is caught. A signed transaction reaches the RPC only insendTransaction, after its intent is recorded, so a declined approval or any refusal leaves the RPC nothing it could land. - Halt and policy approval: a key, or every key, halted with
halt(or by its ownhalt_after_unknown) signs nothing until a person runsresumeat a terminal (Halting); a key withrequire_policy_approvalsigns nothing while its policy digest is not the one approved withkey approve(Policy digest). - Limits: a per-minute rate limit and a daily lamport cap per key, with signs serialized per key. The rate counts every sign of the key in the last minute that was not refused (a refusal sends nothing), including one whose process died after recording its intent. The cap is checked against the simulation of the exact transaction about to be sent: what the key has spent or reserved in 24 h, plus this transaction’s simulated spend and fee, must stay within it. A simulation that does not report the key’s balance, or a missing fee estimate, is refused. The fee is quoted for the exact transaction sent, including after an approval rebuild. Each send is recorded first as an intent carrying that reservation; a spend whose outcome is not known is charged it.
With an instruction_table configured (Configuration), every SAGE, Player Profile and Profile Faction instruction is also compared with a table generated from the programs’ IDLs: its discriminator must be listed, its account count in range, and a profile check whose signer is the key must name the key’s profile. The table is data, not a decoder: the signer still reads no game account. Today it only reports, in findings.
In sign, after the simulation come approval (for a confirm key, then a fresh blockhash and a new simulation), the blockhash check, the duplicate check (a signature already in the audit log is re-signed once with a fresh blockhash, else refused), and the daily cap against that final simulation; only then is an intent recorded and the transaction sent. check evaluates the same checks as sign, through one policy function; the ones only sign refuses (a deny key, a halt, an unapproved policy, the limits and the cap) it reports in sign_preview instead. Each step can end the request, and the outcome says which kind of step stopped it (see Checking and signing):
flowchart TD
P[Payload] --> PRE["Fee payer, chain, programs, tokens,<br/>System Program, signers, new accounts<br/>(sign: also a halt, the rate limit and the cap so far)"]
PRE -->|a check fails| R([refused, exit 10])
PRE --> SIM[Simulate]
SIM -->|the program rejects it| SF([simulation_failed, exit 11])
SIM -->|check| OK([ok, exit 0])
SIM -->|sign| GATE["Approval, blockhash still valid,<br/>not a repeat, daily cap"]
GATE -->|"declined, still stale, a repeat<br/>or over the cap: nothing sent"| R
GATE -->|the re-simulation fails| SF
GATE --> INT[Record the intent in the audit log]
INT --> SEND[Sign, send and poll]
SEND -->|landed| C([confirmed, exit 0])
SEND -->|landed with a program error| F([failed, exit 12])
SEND -->|"no proof it landed:<br/>expired, 150 s, or the RPC kept failing"| U([unknown, exit 14:<br/>reconcile before retrying])
It then signs, sends, and polls (first after 400 ms, then 800 ms, then every second, rebroadcasting every third poll; see RPC calls and latency) until the transaction is confirmed, or until its blockhash is past its last valid height or 150 s pass, which end as unknown. Every sign request is recorded in a hash-chained audit log; Security covers verifying and repairing it. The log is one file that only grows, so a sign reads it from the end and stops early: the rate limit, daily cap and duplicate check read back 48 h (their 24 h window plus a day of slack for a clock stepped back), the halt’s unknown count reads back to the key’s last landed sign or resume, and an append reads only the last line for its hash. audit verify and the console still read every line.
Install and update
Not published on crates.io: install from GitHub, pinned to a release tag (vX.Y.Z). Unix only (macOS, Linux).
Install script
scripts/installer/install.sh installs or updates the signer and the console on the host it runs on, into ~/.local/bin by default. The signer is three binaries from one release: sa-forge-signer, the command line, sa-forge-signer-serve, the service, and sa-forge-signer-mcp, the stdio relay an MCP client starts. It downloads the prebuilt signer for the host and verifies it as below (the attestation needs gh), or builds it from the tag with cargo when there is no prebuilt binary for the host. It builds the console with cargo from the same tag, and skips it for a tag that does not include it. It also links sfs to sa-forge-signer, sfs-serve to sa-forge-signer-serve, sfs-mcp to sa-forge-signer-mcp and sfs-console to sa-forge-signer-console in the same directory (--no-alias skips them), and never replaces an existing file or link of those names. Run it again to update: it skips a signer that is already at that version.
# From a checkout, or download the script from the tag you want and read it first
scripts/installer/install.sh # the latest tag
scripts/installer/install.sh --tag vX.Y.Z --no-console
scripts/installer/install.sh --from-checkout # build both from this checkout's working tree, not a release
scripts/installer/install.sh --setup # then connect Claude Code, as below
scripts/installer/install.sh --help # --prefix, --from-source, --console-ref, --skip-attestation, --force
It installs binaries only. The config, keys and audit log stay in ~/.config/sa-forge-signer/ (see Configuration). Nothing about logging is set at install: tracing is switched on per run with SA_FORGE_SIGNER_LOG in the environment of whatever starts the signer, such as your shell, the serve service, a container or an MCP client’s config (Tracing).
--uninstall removes the binaries and their links, and what --setup added, and never touches the config, keys, tokens or audit log. While key files remain it keeps the signer, which key destroy needs, and lists the steps to retire them (see Key lifecycle): revoke each grant, sa-forge-signer key destroy <name>, then remove the config directory. Run it again afterwards, or add --force to remove the signer anyway.
Connecting Claude Code
--setup makes the agent’s side work in one step once the binaries are in place, after a release install or --from-checkout:
scripts/installer/install.sh --setup # stdio relay, user scope, with the skill
scripts/installer/install.sh --setup --dry-run # print the commands, install and change nothing
scripts/installer/install.sh --setup-http --scope project --replace
It does three things, in this order:
- Creates the agent’s token file with
sa-forge-signer token new agent --out <file>if the file does not exist (--token-file, defaultagent.tokenbeside the config:~/.config/sa-forge-signer/agent.token), and shows the[[serve.tokens]]block it prints. It never edits the config or creates a key: add that block to the config yourself, listing inkeysthe keys the agent may use (a session key, or a wallet key only withallow_unattended = true, for a throwaway testnet key), then restartsa-forge-signer-serve. - Adds the
sa-forge-signerMCP server to Claude Code withclaude mcp add --scope <scope>(--scope userby default;localandprojectbelong to the current directory). By default that is the stdio relay,sa-forge-signer-mcp --token-file <file>, so Claude Code stores only the token’s path;--setup-httpadds the HTTP route with the bearer header instead, which stores the token itself in Claude Code’s config. It refuses when asa-forge-signerserver is already added in another scope, and leaves one in the same scope as it is.--replacereplaces it: a server in another scope is removed only after the new one is added, so a failed add keeps it; one in the same scope has to be removed first, so a failed add there leaves none and says to rerun--setup. - Installs the skill with
sa-forge-signer-mcp skill install(into the current directory with--scope project);--no-skillskips it.
It records what it added in installer-setup beside the config, and --uninstall removes exactly that: the server, from the scope and directory it was added in, and the skill, only while its SKILL.md still carries the mark skill install writes. Uninstall asks before removing anything (-y skips the question); declining, or no terminal to ask on, removes nothing, binaries included, so the server is never left pointing at removed binaries, and --uninstall --dry-run prints what it would remove.
Run --setup as the agent’s OS user, whose Claude Code it configures. The service still runs as the signer’s user (Isolation) and must be running for any call to answer; when that user’s config directory is not the agent’s, pass --token-file a path the agent can read. Without --setup, the manual steps are in Getting started and Serving over HTTP and MCP.
Prebuilt binary
Each release carries sa-forge-signer-vX.Y.Z-<target>.tar.gz for x86_64-unknown-linux-musl, aarch64-unknown-linux-musl (static, any distribution), aarch64-apple-darwin and x86_64-apple-darwin, a SHA256SUMS file, and a build provenance attestation. The archive holds the three binaries, sa-forge-signer, sa-forge-signer-serve and sa-forge-signer-mcp (an older release holds fewer, and the install script then removes any of the other two left from a newer install). Verify before you run it:
gh release download vX.Y.Z --repo ttdonovan/sa-forge-signer --pattern '*-aarch64-apple-darwin.tar.gz' --pattern SHA256SUMS
shasum -a 256 -c --ignore-missing SHA256SUMS
gh attestation verify sa-forge-signer-vX.Y.Z-aarch64-apple-darwin.tar.gz --repo ttdonovan/sa-forge-signer \
--signer-workflow ttdonovan/sa-forge-signer/.github/workflows/release-binaries.yml
tar -xzf sa-forge-signer-vX.Y.Z-aarch64-apple-darwin.tar.gz
The attestation proves the archive was built by this repository’s release-binaries.yml workflow, as run from main; that workflow checks out the tag and refuses one that does not match the version in Cargo.toml. The checksum alone only proves the archive matches the file next to it. The macOS binaries are not signed or notarized: a copy downloaded with a browser is quarantined by Gatekeeper (gh and curl do not set the quarantine flag).
From source
Needs Rust 1.89 or later.
# Find the latest release tag
git ls-remote --tags --refs --sort=-version:refname https://github.com/ttdonovan/sa-forge-signer 'v*' | head -1
cargo install --locked --git https://github.com/ttdonovan/sa-forge-signer --tag vX.Y.Z sa-forge-signer
sa-forge-signer --version
That installs sa-forge-signer, sa-forge-signer-serve and sa-forge-signer-mcp (MCP over stdio). Install a tag, not a branch: main can hold unreleased changes, and a tag is what the changelog describes. --locked builds with the dependency versions in Cargo.lock, the ones CI tested and audited.
Updates
Compare sa-forge-signer --version (or cargo install --list, which shows the installed tag) with the latest tag, read the changelog for anything breaking, then download and verify the new release’s binary, or run the same cargo install with the new tag (it replaces the installed binary). To be notified of new versions, watch the repository on GitHub for releases only (Watch, Custom, Releases), or subscribe to https://github.com/ttdonovan/sa-forge-signer/releases.atom. A running serve keeps the old binary until it is restarted.
To build from a clone instead, cargo build --release --locked uses the toolchain pinned in rust-toolchain.toml.
Getting started
One pass from nothing to an agent signing through serve, first the short path with the install script’s --setup, then each step by hand. It runs everything as your own OS user, which suits testnet: an agent running as you can read the keys (see Isolation before using anything of value). Choosing a setup explains what each setup below can lose.
The short path
scripts/installer/install.sh --setup # install, create the agent token, add the MCP server to Claude Code, install the skill
sfs config init
sfs key new alice-session --class session # or a testnet wallet, as in step 3
Then, as in step 3, add the key’s block and the [[serve.tokens]] block that --setup showed to config.toml, with keys = ["alice-session"], and grant and fund the key. Check it with sfs config show, leave sfs-serve running, and restart Claude Code. Connecting Claude Code says what --setup does and how --uninstall undoes it. The steps below are the same by hand, and the reference for each part: after --setup, skip token new in step 3 and claude mcp add in step 4.
1. Install
scripts/installer/install.sh # from a checkout; see Install and update for other ways
It installs sa-forge-signer, sa-forge-signer-serve, sa-forge-signer-mcp and sa-forge-signer-console into ~/.local/bin and links sfs, sfs-serve, sfs-mcp and sfs-console to them (Install and update). The commands below use sfs.
2. Create the config
sfs config init # writes ~/.config/sa-forge-signer/config.toml, with no keys yet
sfs config show
Keys go in ~/.config/sa-forge-signer/keys/ and the audit log in ~/.config/sa-forge-signer/state/ (see Configuration).
3. Add a key and a token
Pick one setup. Each ends with the whole block to add to config.toml; everything else in the file can stay as config init wrote it.
Testnet: the agent holds a throwaway wallet
The agent’s wallet is the profile authority and signs everything through serve, unattended. Use this only for a testnet burner: anything that controls the agent can sign as the profile authority.
sfs key new alice-wallet --class wallet # or, for a keypair file you have:
sfs key import alice-wallet --class wallet --from ~/path/to/keypair.json
sfs token new agent --out ~/.config/sa-forge-signer/agent.token
Fund the wallet with testnet ZINK: it pays the fees. Then add:
[keys.alice-wallet]
class = "wallet"
pubkey = "<pubkey printed by key new or key import>"
approval = "auto" # sign without a person at a terminal
allow_unattended = true # lets a wallet key be "auto" and sit on a serve token; throwaway testnet keys only
[serve]
[[serve.tokens]]
name = "agent"
sha256 = "<hash printed by token new>"
keys = ["alice-wallet"]
Both extra lines are needed. A wallet key defaults to approval = "confirm", which asks on the terminal running serve for every signature and refuses when there is none. And the config refuses to load a wallet key that is auto or on a token without allow_unattended = true: the signer cannot see what a profile grant gives away.
Recommended: you hold the wallet (Path B)
Your own wallet stays outside the signer and grants the agent a narrow, expiring session key.
sfs key new alice-session --class session
sfs token new agent --out ~/.config/sa-forge-signer/agent.token
Grant the key on your profile with forge-mcp build_add_profile_key (the narrowest permission mask and a short expiry), signed by your wallet, and send it a small amount of ZINK for fees (Key lifecycle). Then add:
[keys.alice-session]
class = "session"
pubkey = "<pubkey printed by key new>"
[serve]
[[serve.tokens]]
name = "agent"
sha256 = "<hash printed by token new>"
keys = ["alice-session"]
A session key defaults to approval = "auto", so nothing else is needed.
Path A as Choosing a setup describes it combines the two: the wallet key stays on confirm and off the token, and you add a session key as above, granted and funded by the wallet through sfs sign --key alice-wallet at a terminal.
4. Check, run and connect
sfs config show
For the testnet setup it ends like this (the session setup shows class: session, approval: auto and no allow_unattended line):
[keys.alice-wallet]
pubkey: 6YAf723681zcJjFrd9UDJCtymiTGov8FL9Ed6XvA2evw
class: wallet
approval: auto
key file: present
profile: -
rate limit: 30 per minute
daily cap: 50000000 lamports (0.05 ZINK)
transfer_to: -
extra_programs: -
allow_unattended: true (throwaway testnet keys only)
[serve]
listen: 127.0.0.1:8790
allowed_hosts: 127.0.0.1, localhost
token: agent (keys: alice-wallet)
key file: missing means the key is in the config but not in the key directory. The daily cap and rate limit are the defaults; raise them per key if the agent hits them (Configuration).
sfs-serve # leave it running; 127.0.0.1:8790
claude mcp add --transport http sa-forge-signer http://127.0.0.1:8790/mcp \
--header "Authorization: Bearer $(cat ~/.config/sa-forge-signer/agent.token)"
The agent passes each forge-mcp build_* payload to the check tool, then sign (Serving over HTTP and MCP). To watch it, run sfs-console in another window (Console).
Choosing a setup
Which keys the agent’s signer holds decides what the agent can do alone and what it can lose.
flowchart LR
subgraph A["Path A: the signer holds"]
W["Wallet key (index 0)<br/>profile authority, confirm"] -->|grants and funds| SA["Session key (index 1 or later)<br/>SAGE-scoped"]
end
subgraph B["Path B: the signer holds"]
SB["Session key only<br/>narrow mask, short expiry"]
end
H["The human's wallet<br/>(outside the signer)"] -->|grants and funds| SB
Path A: the agent holds the wallet
The agent’s wallet is the profile authority (key index 0) and holds its assets; a SAGE-scoped session key (a later index) signs day-to-day play. The wallet key signs onboarding, deposits and withdrawals, vault funding and session-key funding.
Caution. On mainnet this makes the agent’s wallet a hot wallet. Anything that controls the agent, including a prompt injection in content it reads, can sign as the profile authority: withdraw assets, add an attacker’s key, or drain the wallet. The signer cannot see what a wallet transaction does.
walletkeys therefore default toconfirm(a human approves each signature on the terminal). That terminal must be one the agent cannot type into: an agent running as the same user as a tmux session or a scriptable terminal app can answer the prompt itself (see Security). Keep only working balances in the agent’s wallet. Path A suits testnet burners; on mainnet it is a deliberate, informed choice.
Path B: the human holds the wallet
The human sets up the profile, character, faction, vault and deposits with their own wallet, then grants the agent a narrow, expiring session key. The agent’s signer holds only that key.
Caution. A session key is limited to the permissions granted, not to harmless actions. Within its mask it can spend in-game resources, spend from the profile vault and put fleets at risk. Grant the narrowest mask that covers the agent’s job, a short expiry and a small ZINK balance, and deposit only what the agent should be able to lose.
Recommendation: Path B for mainnet and anything of value; Path A for testnet.
Isolation
flowchart LR
AG[Agent] -->|build_*| FM["forge-mcp<br/>(local or hosted)"]
FM -->|unsigned payload| AG
subgraph SU["The signer's OS user or container"]
SG[sa-forge-signer] --- KC[("Keys, config<br/>and audit log")]
end
AG -->|"check / sign<br/>(CLI, HTTP or MCP)"| SG
SG -->|signed transaction| RPC[Z.ink RPC]
AG -.-x|must not read| KC
The signer only protects anything if the agent cannot read its keys or config. Run it as a different OS user or in a container, with the key directory and config owned by that user. If the agent can read the key file, the checks are advice, not a control.
Checking and signing
# Generate a key; prints only the pubkey and a config snippet
sa-forge-signer key new alice-session --class session
# Configure it (see Configuration), then grant it on the profile with
# forge-mcp build_add_profile_key, signed by the profile authority.
sa-forge-signer check payload.json # checks + simulation, never signs
sa-forge-signer sign --intent "scan round" payload.json
sa-forge-signer key show alice-session
sa-forge-signer key fund alice-session --zink 0.05 --from alice-wallet --out fund.json
sa-forge-signer key destroy alice-session
sa-forge-signer halt --key alice-session --reason "fleet looks wrong"
sa-forge-signer resume --key alice-session # at a terminal only
sa-forge-signer key approve alice-session # at a terminal only
sa-forge-signer audit verify
The config lives at ~/.config/sa-forge-signer/config.toml unless --config or SA_FORGE_SIGNER_CONFIG says otherwise (see Configuration); a key’s whole life is in Key lifecycle. The key is chosen by --key, or by matching the payload’s fee payer.
The report
check and sign print one JSON report (outcome, signature, slot, explorer, detail, failed_check, program_error, error, checks, sign_preview, findings, compute_units, fee, balance_change, summary, logs). checks lists the checks that passed; on a refusal failed_check names the one that failed. sign_preview is set by check only (null from sign): refusals lists, as {"check", "detail"} in check order, every check sign would refuse now that check does not run as a refusal: approval for a deny key, halt (Halting), policy for a policy not yet approved (Policy digest), and limits for the rate limit or the daily cap, counted against check’s own simulation once it passes. needs_approval is true for a confirm key, whose sign waits for a person at the terminal. check’s outcome does not change with it, so a deny key still checks ok. The preview is as of the check: other signs with the key, and time passing, change the limits before a later sign. findings is present only when an instruction_table is configured and it has something to say: {"check", "detail"} items for an instruction the table does not list (instructions), the wrong number of accounts (accounts), or a profile check whose signer is this key naming a profile other than the key’s profile (profile). In report mode findings never refuse; a sign records them in its audit entry. program_error is the program’s own message from the logs, on a failed simulation or a transaction that landed with an error. For a landed transaction, balance_change and fee come from the transaction’s own metadata, so parallel transactions from the same key do not skew them; for check they are simulated. The payload’s summary comes from the builder and is not verified.
Outcomes
| Outcome | Exit | Meaning | Retry? |
|---|---|---|---|
ok | 0 | check passed | - |
confirmed | 0 | landed | no |
refused | 10 | a check failed | fix the payload; after failed_check blockhash, nothing was sent and a retry is safe |
simulation_failed | 11 | the program would reject it | after fixing the cause |
failed | 12 | landed with a program error | after fixing the cause |
unknown | 14 | sent but not proven landed: the blockhash expired with no status found, 150 s passed, or the RPC kept failing | only after reconciling |
Before anything is sent, the blockhash check compares the transaction’s last valid height with the finalized height: a blockhash already past it can never land, whichever node reports the height (a lagging RPC node can hand out an expired blockhash). It is re-signed and simulated once with a fresh one; if that is expired too, the sign is refused and nothing is sent.
The same instructions signed with the same blockhash give the same signature, so a payload sent twice within one blockhash would resend the first transaction, and the second sign would report the first one’s outcome as its own. Before the intent is recorded, the duplicate check looks up the transaction’s signature among the key’s audit entries from the last 24 h. On a match it is re-signed and simulated once with a fresh blockhash; if the RPC returns the same blockhash again, the sign is refused with failed_check blockhash, nothing is sent, and a retry is safe.
Exit code 13 (expired) is retired. An RPC cannot prove that a transaction never landed: behind a load balancer, the finalized height and the status lookup can come from different nodes, and a node can lack history. So an expired blockhash with no status found is reported as unknown, with that in detail.
Retrying safely
After unknown, an HTTP 500, a dropped connection or a restarted service, the transaction may still have landed. Before building the action again:
- Look up the reported signature (or the
signatureof the key’s lastsign-intententry in the audit log). - Re-read the game state the action was meant to change (the fleet’s state, the cargo, the balance).
- Retry only if the action did not happen.
The per-key lock and the intent entry stop concurrent signs from racing the limits; they do not stop a caller from sending the same action twice with a new blockhash.
Halting
sa-forge-signer halt --reason <text> stops signing with every key, and --key <name> with one. It takes effect on the next sign, in the CLI and in a running serve alike: every sign reads it under the key lock and is refused with failed_check halt and the reason, and nothing is sent. A sign already running when the halt is written finishes. check still runs and lists the halt in sign_preview. The halt is a file under state_dir/halts/, and it and its lifting are recorded in the audit log as halt and resume entries (key * for every key).
Only sa-forge-signer resume lifts a halt, and only after someone types yes at the controlling terminal (never stdin; without a terminal it refuses). resume --key <name> lifts that key’s own halt, and resume alone lifts the halt on every key; a key’s own halt is not lifted by it. serve has a halt route and MCP tool but no way to resume (Serving over HTTP and MCP). key show prints whether the key is halted.
A key also halts itself after halt_after_unknown consecutive unknown outcomes (default 3, 0 never; see Configuration), a sign that the RPC or the transactions are in trouble. A landed outcome (confirmed or failed) or a resume resets the count; signs that sent nothing neither count nor reset it. The sign that trips it says so in detail. Reconcile those signatures before resuming.
Policy digest
Each key has a policy digest: the sha256 of everything that decides what it may sign, namely its resolved settings (defaults filled in, lists sorted), the name and sha256 of each serve token that lists it, the cluster, and the instruction table’s sha256 and mode. config show and key show print it, and every sign and sign-intent audit entry records it in policy, so the log says which policy each signature was made under. Changing any of those changes the digest; the RPC URL, the explorer and the directories do not count.
With require_policy_approval = true the key signs only while its digest is the one last approved with sa-forge-signer key approve <name>, which shows the current and the approved digest and needs someone to type yes at the controlling terminal (never stdin; without a terminal it refuses). Any other digest is refused with failed_check policy; check still runs and lists it in sign_preview. The approval is a file under state_dir/policy/ and an approve audit entry carrying the digest. key show says whether the current digest is approved. The same OS user writes the config and state_dir, so this is a record and a deliberate second step, not a barrier against that user.
Partial signers
Payloads that carry partial signers hold private keys for new accounts; sign deletes such a payload file once it has been sent, and never logs them.
Key lifecycle
- Create:
sa-forge-signer key new alice-session --class sessionprints only the pubkey; add the printed[keys.alice-session]block to the config. To use a key you already have,sa-forge-signer key import alice-session --class session --from <file>copies a Solana CLI keypair file (a JSON array of 64 bytes, assolana-keygenwrites) intokey_diras a 0600 file and prints the same block. Importing the same keypair again writes nothing and prints the block again; a different key under that name is refused, never overwritten. It leaves the original in place: delete it once the copy works (key showreportskey file: ok). - Grant: build forge-mcp
build_add_profile_keywith that pubkey asnewKey, scopesage, the narrowest permission mask and a shortexpireTime. The profile authority signs it: the human’s own wallet (Path B; the grant page builds and signs the grant with a browser wallet), or the agent’swalletkey throughsign(Path A; it needsconfirmor a testnet burner onauto). The key lands at the profile’s next free index, which is not always 1: read it from the profile (the grant page shows it) and give it to the agent, which passes it as forge-mcp’skeyIndex. The signer config has no index field. - Fund:
sa-forge-signer key fund alice-session --zink 0.05 --from <payer> --out fund.jsonbuilds an unsigned transfer. Path A:sign --key alice-wallet fund.json, with the session key listed in the wallet’stransfer_to. Path B: pay it from the human’s wallet with any Solana-compatible tool. A small balance doubles as a spending cap. - Use:
signwith the key. A missing or too narrow grant fails the simulation with the program’s error. - Revoke: build
build_remove_profile_key, signed by the profile authority (or Revoke on the grant page), thensa-forge-signer key destroy alice-session.destroyzeroes and deletes the key file, and refuses while the key still holds ZINK unless you pass--abandon-balance. The on-chain expiry is the backstop if revocation is forgotten.
Configuration
The config lives at ~/.config/sa-forge-signer/config.toml unless --config or SA_FORGE_SIGNER_CONFIG says otherwise. sa-forge-signer config init writes the example below there (mode 0600, in a 0700 directory it creates if needed) and refuses to overwrite an existing file. It has no keys yet: add the block key new prints for each key, and [serve] if you run serve.
sa-forge-signer config show prints the config as the signer resolves it: the paths, the RPC, and the instruction table if one is set, each key with its effective class, approval, limits, policy digest and whether its key file exists, defaults filled in, then [serve] with each token’s name and keys. It reads no key material, prints no token hashes, and calls no RPC.
Settings
Only class and pubkey on each key, and a token for serve, have to be set; everything else has a default.
| Setting | Default | Set it when |
|---|---|---|
cluster | "zink-testnet" | never: it is the only cluster |
rpc_url | the cluster’s public RPC (https://rpc1.z.ink) | you use another RPC you trust (Security). It must be https://; http:// loads only for a loopback host (127.0.0.1, ::1, localhost), such as a local validator, so signed transactions and any API key in the URL never cross a network in clear |
explorer_url | the cluster’s explorer (https://explorer.z.ink) | you prefer another block explorer. Used only for links: the explorer field of a sign report and the console’s links; the signer never calls it |
key_dir | keys, beside the config | keys live elsewhere (in Docker, /run/keys) |
state_dir | state, beside the config | the audit log lives elsewhere (in Docker, /var/lib/sa-forge-signer) |
instruction_table | none | you want the instruction table’s findings: { path = "...", sha256 = "...", mode = "report" }, the path relative to the config. The signer refuses to start if the file’s sha256 differs or it is for another cluster. report adds findings to each report and audit entry and never refuses; an enforcing mode comes later |
[keys.<name>] class | required | "session" for a SAGE-scoped profile key, "wallet" for the profile authority |
pubkey | required | the pubkey key new or key import printed |
profile | none | you want key show and the MCP key_show tool to report the key’s profile, or you configure an instruction_table: its profile finding holds the key to this profile |
approval | "auto" for a session key, "confirm" for a wallet key | a key should never sign ("deny"), or a testnet wallet should sign unattended ("auto", with allow_unattended) |
allow_unattended | false | a throwaway testnet wallet key must be "auto" or on a serve token |
rate_limit_per_minute | 30 | the agent signs faster than that |
daily_lamport_cap | 50000000 (0.05 ZINK) | the key spends more in 24 h (fees and transfers it signs) |
halt_after_unknown | 3 | the key should halt after more or fewer consecutive unknown outcomes; 0 never halts it |
require_policy_approval | false | a change to the key’s policy should stop it signing until a person runs key approve <name> at a terminal (Policy digest) |
max_compute_units | 1400000 (the runtime’s maximum) | the key’s payloads should be held to a lower SetComputeUnitLimit |
max_compute_unit_price | 0 (no priority fee) | the key may pay a priority fee: the highest SetComputeUnitPrice, in micro-lamports per compute unit |
transfer_max_lamports | none (only the daily cap) | the key’s System transfers, summed over one transaction, should stay under a smaller amount |
transfer_to | none | the key funds other accounts with ZINK, such as a wallet funding its session key or vault |
extra_programs | none | the key must call a program outside the built-in list (How it works); add only programs you trust |
[serve] listen | "127.0.0.1:8790" | serve should listen elsewhere (in Docker, "0.0.0.0:8790") |
allowed_hosts | ["127.0.0.1", "localhost"] | clients reach serve under another host name |
[[serve.tokens]] | none: serve will not start | always, for serve: name, the sha256 token new printed, and the keys it may use |
The example, as config init writes it:
# sa-forge-signer config. Default location: ~/.config/sa-forge-signer/config.toml
# Relative paths resolve against this file's directory.
cluster = "zink-testnet"
# rpc_url = "https://rpc1.z.ink" # default for the cluster; https only (http only on loopback)
# explorer_url = "https://explorer.z.ink" # default for the cluster; for links only
# key_dir = "keys" # 0700; one 0600 file per key
# state_dir = "state" # audit log
# Report what the instruction table (just apps idl-table) says about each payload; never refuses.
# instruction_table = { path = "zink-testnet.json", sha256 = "SHA256_OF_THE_FILE", mode = "report" }
# A SAGE-scoped session key. Signs without asking. The config holds no key index: the agent passes
# the key's profile index (the next free one when granted, not always 1) as forge-mcp's keyIndex.
# Paste the block `sa-forge-signer key new <name> --class session` prints, or uncomment and fill in.
# [keys.alice-session]
# class = "session"
# pubkey = "PUBKEY_FROM_key_new"
# profile = "PROFILE_ADDRESS"
# rate_limit_per_minute = 30
# daily_lamport_cap = 50000000 # 0.05 ZINK
# halt_after_unknown = 3 # halt the key after this many unknown outcomes in a row; 0 never
# require_policy_approval = false # sign only while the policy digest is the one `key approve` accepted
# max_compute_units = 1400000 # highest SetComputeUnitLimit
# max_compute_unit_price = 0 # highest SetComputeUnitPrice (micro-lamports); 0: no priority fee
# extra_programs = [] # more programs this key may call at the top level. They are
# # NOT trusted as game programs: token accounts can't be created
# # for accounts they own. Add only programs you trust.
# Path A only: the profile authority and asset wallet held by the agent.
# Defaults to approval = "confirm": each signature is approved on the terminal.
# A wallet key cannot be "auto" or listed on a serve token: it signs only through the CLI
# with a person at the terminal. allow_unattended = true lifts both rules; use it only for a
# throwaway testnet key, since the signer does not decode what a profile grant gives away.
# [keys.alice-wallet]
# class = "wallet"
# pubkey = "WALLET_PUBKEY"
# transfer_to = ["PROFILE_ADDRESS", "SESSION_KEY_PUBKEY"] # fund the vault and the session key
# transfer_max_lamports = 100000000 # all transfers in one transaction
# `sa-forge-signer-serve`: HTTP at /v1/check and /v1/sign, MCP at /mcp.
# [serve]
# listen = "127.0.0.1:8790"
# allowed_hosts = ["127.0.0.1", "localhost"]
# [[serve.tokens]] # from `sa-forge-signer token new <name> --out <file>`
# name = "agent"
# sha256 = "HASH_PRINTED_BY_token_new"
# keys = ["alice-session"]
Tracing
Tracing is off by default. Set SA_FORGE_SIGNER_LOG to error, warn, info, debug or trace to write spans and events to stderr (stdout still carries only the JSON report), and SA_FORGE_SIGNER_LOG_FORMAT=json for one JSON object per line instead of the compact text format. An unknown value stops the signer with an error rather than being ignored. Each span logs its duration (time.busy, time.idle) when it closes:
| Span | Level | What it times |
|---|---|---|
sign, check | info | the whole request, with the key name and payload hash; ends with a done event carrying outcome, failed_check and sent |
key_lock | info | waiting for the key’s signing lock |
audit_read | info | reading the key’s recent audit entries, with how many were read |
approval | info | waiting for a person at the terminal (confirm keys) |
confirm | info | sending and polling until the outcome is known |
audit_append | info | writing one audit entry, with its command |
rpc | debug | each RPC call, with its method name |
request | info | each serve request, with the method, path, status and token name |
While tracing is on, serve no longer prints its plain access line (POST /v1/sign -> 200 token=agent), so a JSON log holds only JSON: the request span carries the same method, path, status and token name, plus the duration.
Spans never carry key material, partial signers, tokens or their hashes, the RPC URL (it may hold an API key), or caller-supplied text (summary, intent).
Serving over HTTP and MCP
sa-forge-signer-serve runs the signer as a long-lived service (called serve in this book, after its [serve] config table). It is its own binary, installed beside sa-forge-signer, and takes the same --config; it replaces the sa-forge-signer serve subcommand, which no longer exists. As a service, each signature skips process start-up and keys stay loaded in one isolated place. It listens on [serve] listen (default 127.0.0.1:8790):
POST /v1/checkandPOST /v1/sign: body{"payload": <payload object>, "key": "optional", "intent": "optional"}; the reply is the same JSON report as the CLI.4xxmeans a request or auth problem,500that the signer could not finish (seeerror).POST /v1/halt: body{"reason": "...", "key": "optional"}; halts that key, or every key the token lists, until a person runsresumeat the signer’s terminal (Halting). The reply is{"halted": [<keys>]}. A key the token does not list is403; a missing or emptyreason(or one over 500 characters) is400. There is no resume route.POST /mcp: MCP, protocol revision2026-07-28only (JSON responses, no streaming; MCP conformance lists the rules it follows and what a client of an earlier revision sees) with toolscheck,sign,key_showandhalt(the same arguments as/v1/halt), and no resume.key_showreturns the key’spubkey,class,approval,profileandbalance_lamports,halted(null, orsince,byandreason) andpolicy(digest,approval_required,approved; see Policy digest), so an agent can tell whether the key will sign before it builds a payload. Neither a halt nor an approval can be lifted or given throughserve. It also serves four read-only resources that tell an agent how to use the tools.GET /health: no auth, for health checks.
Every other route needs Authorization: Bearer <token>. Create a token with sa-forge-signer token new <name> --out <file>: it writes the token to a new 0600 file and prints only its sha256 for [[serve.tokens]], which also lists the keys that token may use. Host and Origin headers must name an entry in allowed_hosts. confirm keys prompt on the service’s own terminal; without one they refuse.
The smallest [serve] table is the [serve] line and one [[serve.tokens]] entry: listen defaults to 127.0.0.1:8790 and allowed_hosts to 127.0.0.1 and localhost, so set them only to listen elsewhere (in Docker, listen = "0.0.0.0:8790"). serve refuses to start without a token, and every key a token lists must be in the config. A wallet key can be on a token only if it sets allow_unattended = true, or the config does not load; to sign through serve without a person at its terminal it also needs approval = "auto". Both are for throwaway testnet keys only (Getting started).
On SIGTERM or SIGINT the service stops taking requests and lets running ones finish, for up to 220 s, then exits. A sign waits up to 150 s for an outcome, and the poll that crosses that deadline can make four more RPC calls of up to 15 s each. Give it a stop grace period of at least 230 s (for example docker stop -t 230, or TimeoutStopSec=230). That covers the usual case, not every case: the checks before sending also call the RPC, and a stalled RPC can stretch them. A sign cut off by the stop has already recorded its intent; a client that loses the connection during sign must treat it as unknown and reconcile before retrying.
MCP clients pass the payload object from the forge-mcp build_* result unchanged, and call check before sign. In a 15-payload test every payload arrived byte-exact, but the model re-types each one, so each costs its tokens twice and any partial-signer secret appears twice in the transcript.
Add it to Claude Code with claude mcp add --transport http sa-forge-signer http://127.0.0.1:8790/mcp --header "Authorization: Bearer $(cat <file>)", or let the install script’s --setup create the token and add it. The client must speak MCP 2026-07-28: an earlier one is refused at its initialize with an error naming that revision.
Resources and the skill
The MCP route lists four resources (resources/list, resources/read), all static text written for the agent, with nothing from the config, the keys or the audit log:
| Resource | Holds |
|---|---|
sa-forge-signer://llms.txt | The index: what the signer is, and a line on each tool and resource. The server’s instructions tell a client to start here |
sa-forge-signer://llms-full.txt | The guide and the tool reference in one file |
skill://sa-forge-signer/SKILL.md | The guide: sending an action, acting on each outcome, reconciling an unknown one, and when to halt |
skill://sa-forge-signer/references/tools.md | Each tool with its arguments, and every field of the report |
The last two are the skill in skills/sa-forge-signer/, built into the binaries, so what the service serves and what gets installed are the same files. To install it as a Claude skill on the agent’s side:
sa-forge-signer-mcp skill print # the skill file, on standard output
sa-forge-signer-mcp skill install # ~/.claude/skills/sa-forge-signer/
sa-forge-signer-mcp skill install --project . # ./.claude/skills/sa-forge-signer/
skill install writes SKILL.md and references/tools.md, replaces only a copy it wrote earlier, and refuses to touch a SKILL.md of that name it did not write. Neither command needs the token or a running service.
MCP over stdio
sa-forge-signer-mcp is for an MCP client that starts its servers as child processes and talks to them on standard input and output. It does not replace sa-forge-signer-serve: the service must be running as its own process, and a stdio setup is the two together. It is a third binary from the same crate and a client of that service, not a second signer: it forwards each JSON-RPC message to the service’s /mcp route with the bearer token, adding the HTTP headers that revision requires from the message itself, and writes the reply back, so the tools, their arguments and every check are the ones above.
| Process | Started by | Runs as | Holds |
|---|---|---|---|
sa-forge-signer-serve | you: a terminal, a service manager or Docker | the signer’s OS user | the keys and the config |
sa-forge-signer-mcp | the agent’s MCP client, as a child process | the agent’s OS user | the service’s URL and a token |
A client that supports MCP over HTTP, as Claude Code does, does not need it: point the client at the service’s /mcp route as above.
sa-forge-signer-mcp --token-file ~/.config/sa-forge-signer/agent.token # --url defaults to http://127.0.0.1:8790
claude mcp add sa-forge-signer -- sa-forge-signer-mcp --token-file ~/.config/sa-forge-signer/agent.token
- It reads no config and no key, and cannot sign on its own: the MCP client starts it as the agent’s OS user, which the isolation rule keeps away from both. It holds what the HTTP setup already gives the agent, the service’s URL and a token.
--urlmust behttps://, orhttp://only to a loopback host (127.0.0.1,::1,localhost), so the token never crosses a network in clear.--token-fileis required; the file’s content is the token, with surrounding whitespace removed.- Without a running
sa-forge-signer-serveevery request fails. A request the service cannot be reached for, or answers without a JSON-RPC reply (a wrong token is HTTP401), comes back as a JSON-RPC error with code-32603naming the cause. For asignthat means the outcome isunknown: reconcile before retrying. - It waits up to 240 s for each reply, longer than the service’s own 220 s drain.
confirmkeys still prompt on the service’s terminal, never here. - The release archive carries it beside the other two, and the install script installs it with them;
cargo installfrom a tag builds all three.
Running in Docker
The Dockerfile builds a small non-root image (uid 10001) with base images pinned by digest. It holds both binaries: the service, sa-forge-signer-serve, is the entrypoint, and the command line runs with --entrypoint sa-forge-signer. Both read the config from SA_FORGE_SIGNER_CONFIG, which the image sets to the path below. It holds no keys or config; mount them at run time:
| Mount | Path in the container | Notes |
|---|---|---|
| config | /etc/sa-forge-signer/config.toml (read-only) | set key_dir = "/run/keys" and state_dir = "/var/lib/sa-forge-signer"; for serve, listen = "0.0.0.0:8790" |
| keys | /run/keys (read-only) | a 0700 directory of 0600 key files, readable by uid 10001; nothing else should mount it |
| state | /var/lib/sa-forge-signer | the audit log; keep it across restarts |
docker build -t sa-forge-signer .
# One-shot: check or sign a payload from stdin
docker run --rm -i --read-only --cap-drop ALL --entrypoint sa-forge-signer \
-v "$PWD/config.toml:/etc/sa-forge-signer/config.toml:ro" -v "$PWD/keys:/run/keys:ro" \
-v "$PWD/state:/var/lib/sa-forge-signer" sa-forge-signer check - < payload.json
# Service (the entrypoint): publish on loopback only; allow at least 230 s to stop so signs drain
docker run -d --name sa-forge-signer --read-only --cap-drop ALL --stop-timeout 230 \
-p 127.0.0.1:8790:8790 \
-v "$PWD/config.toml:/etc/sa-forge-signer/config.toml:ro" -v "$PWD/keys:/run/keys:ro" \
-v "$PWD/state:/var/lib/sa-forge-signer" sa-forge-signer
The signer talks only to the RPC in rpc_url (by default Z.ink testnet). An agent can use a forge-mcp running anywhere, local or hosted: only unsigned payloads travel to the signer, and the keys never leave this container. The isolation rule still applies: the agent must not be able to read the keys directory or the config, and must not be able to use Docker, which can mount them. On macOS, bind-mounted files stay owned by your macOS user, so an agent running as you can read the keys (see Security).
Security
Threat model
| Threat | Control |
|---|---|
| The agent reads the key file and skips every check | Keys and config are unreadable by the agent’s OS user (separate user or container). Key files must be 0600 in a 0700 directory or the signer refuses to load them. |
| A buggy or malicious payload (from the builder, a hand-written file or a prompt injection) | The structural checks, simulation before sending, confirm for wallet keys, then the on-chain key limits. |
| A wrong or swapped RPC URL | The RPC’s genesis hash must match the cluster profile. A process re-reads it at least every 5 minutes, so a URL that starts answering for another chain is refused within that time. |
Private keys inside payloads (partial_signers) | Never logged; the audit log hashes the payload without them; the payload file is deleted once sent. |
| Secrets in logs | Tracing is off unless SA_FORGE_SIGNER_LOG is set, and its spans carry key names, payload hashes, RPC method names, counts and outcomes only: never key material, partial signers, tokens or their hashes, the RPC URL, or caller-supplied text. |
An RPC landing a transaction the signer declined or refused (a confirm answered no, a cap or duplicate refusal) | The RPC is sent only unsigned copies to simulate. A signed transaction leaves the signer only in sendTransaction, after every check, the approval and the sign-intent entry. |
| Approval spoofed by the agent | confirm reads only from the controlling terminal (/dev/tty), never stdin. Without a terminal it refuses. This holds only while nothing the agent runs can type into that terminal (see Not covered). |
Caller text redrawing an approval prompt (escape sequences or newlines in a payload summary or a token’s halt reason) | The confirm and resume prompts, and key show, print caller text escaped: control and bidirectional formatting characters appear as \u{1b}-style text, never as themselves, and at most 200 characters of it. confirm prints the signer’s own figures (simulated balance change, compute units) before the summary, on separate lines. |
| A stolen session key | On-chain scope, narrow permission mask, short expiry and a small ZINK balance. These hold even if the signer host is compromised. |
| A stolen wallet key (Path A) | Nothing on-chain limits it. Keep only working balances in it. |
| An MCP client that speaks only stdio, started as the agent’s OS user, needing the keys to sign | sa-forge-signer-mcp is a client of the running service: it holds its URL and a bearer token, reads no key or config, and refuses a URL that is not https or loopback http. Every check still runs in the service. |
Another local process or a web page calls serve | Every route but /health needs a bearer token; config stores only its sha256 and the keys it may use. Host and Origin must be an allowed host. |
| A restart mid-sign | serve drains running requests on SIGTERM/SIGINT (up to 220 s) before exiting; a dropped connection during sign means unknown, not failed. |
| Double actions after a timeout | Nothing short of a landed status is reported as final: an expired blockhash with no status found is unknown, since an RPC cannot prove absence (see below). Callers must reconcile the signature and the on-chain state before retrying (Retrying safely). |
| The same payload signed twice within one blockhash (identical bytes, so the second send is the first transaction and would report its outcome) | Before the intent is recorded, a signature already in the key’s audit entries from the last 24 h is re-signed once with a fresh blockhash; if the RPC returns the same blockhash, the sign is refused (blockhash) and nothing is sent. |
An operator, or the agent itself, needs to stop a key now (serve otherwise stops only with an edit and a restart) | halt (CLI, or a token’s /v1/halt and MCP halt for the keys it lists) refuses every later sign until resume at a terminal; nothing lifts it on its own, and serve cannot. halt_after_unknown trips it after repeated unknown outcomes. |
| One payload spending the daily cap as a priority fee, or a large transfer to an allowed destination | ComputeBudget is limited to SetComputeUnitLimit up to max_compute_units and SetComputeUnitPrice up to max_compute_unit_price (default 0), each once; transfers must come from the signing key and, with transfer_max_lamports, stay within it per transaction. |
| A transaction that takes the key past its daily cap | The cap is checked under the key lock, after the final simulation, against spent-or-reserved plus this transaction’s simulated spend and fee; with no simulated balance or no fee estimate the sign is refused. The simulated balance usually includes the fee already, so this over-reserves by one fee rather than ever under-counting. |
| A token using up the rate limit of a key it may not use, by naming that key in refused signs | A refused sign, authorized included, counts toward no limit: the rate counts only signs of the key that were not refused, so neither another token nor an agent retrying its own refusals can hold a key at its rate. The refusal is still recorded in the audit log under the key’s name. |
| Concurrent signs racing the limits | Signs with one key are serialized by a file lock, and every send is recorded as an intent with its reservation before it leaves; unresolved spends are charged that reservation. |
| Rent paid out through token-account creation | Associated Token creates must name the real System and SPL Token or Token-2022 programs, and may create accounts only for the signing key, its transfer destinations, or an existing account owned by one of the cluster’s built-in game programs (not a key’s extra_programs). |
| A change to a key’s policy taking effect silently at the next start | config show and key show print a digest of each key’s policy, and every sign’s audit entry records it. With require_policy_approval the key signs only while its digest is the one approved with key approve at a terminal. The same OS user writes the config and state_dir, so this is a record and a deliberate step, not a barrier against that user. |
Not covered
- Token movement inside an allowed program (SAGE moving cargo or ATLAS by CPI) is limited only by on-chain permissions; the signer refuses direct token-program instructions but does not decode game instructions.
- The signer does not decode what a profile grant gives away. A
walletkey therefore cannot beautoor reachable throughserveunless it setsallow_unattended = true; do that only for a throwaway testnet key. - Typing into the approval terminal. This applies to
resumeandkey approveas much as toconfirm.confirmcannot tell a person’s keystrokes from input injected into its terminal. A process running as the user who owns that terminal can usually inject input:tmux send-keys,screen -X stuff, and the scripting or remote-control APIs of terminal apps (iTerm2, kitty, WezTerm, cmux and similar). An agent running as that user can therefore typeyesat the prompt. Answerconfirmprompts in a terminal the agent’s OS user cannot script: not a multiplexer session or terminal app the agent’s user can reach. - A container alone does not keep the keys from the agent. It does only if the agent’s OS user can neither read the host files behind the mounts nor use Docker. Anyone who can reach the Docker socket (on Linux, the
dockergroup; on macOS, the user running Docker Desktop) can start a container that mounts the keys and reads them. On macOS, bind-mounted files also stay owned by the macOS user who owns the folder, whatever uid the container sees, so an agent running as that user can read the keys directly. Run the agent as a user that owns none of the signer’s files and cannot use Docker, or inside its own container or VM with no Docker socket. - The daily cap measures the fee payer’s lamports, not token or profile-vault spending; only on-chain permissions limit those.
- The RPC is trusted for outcomes. The genesis check stops a wrong cluster, not a dishonest or inconsistent node. No RPC answer can prove that a transaction never landed: a load balancer can serve the finalized height and the status lookup from different nodes, and a node can lack history. The signer therefore never reports a transaction as not landed. A lying RPC can still report a false
confirmed, or a simulation that hides a spend. Use an RPC you trust, and keep the key’s ZINK balance small. - Rent for game-owned token accounts. A token account created for a game account (for example another player’s character or cargo cache) holds its rent where only the game program can move it. A requester can direct that rent into game accounts they control, if the game has an instruction that closes such accounts back to them. It is bounded by the daily cap and is about 0.002 ZINK per account.
- Wall-clock bounds. The 150 s confirmation deadline does not include the checks and simulations before sending, and each RPC call may take up to 15 s. A stalled RPC can make one sign take a few minutes.
- The audit log’s hash chain detects edited entries, not truncation or a rewrite by someone who can write
state_dir: keep that directory out of agents’ reach, and copy the latest hash elsewhere if you need an anchor.summaryandintentare caller-supplied and recorded as given, up to 500 characters each (longer text is cut and marked[truncated]); do not put secrets in them. - Signs read the audit log back only 48 h (see How it works), so a damaged or edited line older than that does not stop signing: only
audit verifyfinds it. Run it regularly. If the system clock steps back by more than a day, entries written before the step can drop out of the rate limit, daily cap and duplicate check.
Repairing the audit log
A crash during an append can leave a partial last line. Every later read then fails, and every sign with every key fails until the line is repaired. Repair it without losing what the log proves:
- Stop the service and any CLI signs.
- Copy
state_dir/audit.jsonlsomewhere safe. - Run
sa-forge-signer audit verify. The error names the first unreadable line. - If that line is the last one and is a cut-off JSON fragment, move it into a separate file next to the log (for example
audit.jsonl.partial-2026-10-02) rather than deleting it, and leave every earlier line unchanged. Runaudit verifyagain; it should pass. If any line other than the last fails, the log was edited: stop and investigate, do not repair. - Reconcile before signing again. A cut-off
sign-intentmeans nothing was sent: the intent is written before the send, and the send never happens if that write fails. A cut-off finalsignentry means the transaction was sent. Its intent is still in the log as an orphan and keeps its reservation charged for 24 h. Look up that intent’ssignatureand re-read the on-chain state before retrying the action.
Do not delete or rewrite earlier entries, including orphan intents: they are what keeps unresolved spends charged.
Reporting
Report vulnerabilities privately through GitHub’s “Report a vulnerability” on this repository.
Contributing
Prerequisites
- Rust, at the version pinned in
rust-toolchain.toml - just
- shellcheck and cargo-audit, for
just check - mdBook 0.5, mdbook-mermaid and mdbook-llms, for the docs:
just docs setupinstalls all three
Workflow
Run just to list recipes. Before opening a pull request, run just check (the same gate as CI: fmt, clippy with warnings as errors, tests, shellcheck, advisories, dependency age), and just docs check if a doc changed. Keep PLAN.md in step with the work it tracks. For a change that may affect speed, run just bench (timings of the audit log’s reads on synthetic logs) before and after, and set SA_FORGE_SIGNER_LOG=info (see Configuration) to see where a real sign spends its time.
To try the MCP route from a Claude Code session in this checkout, .mcp.json adds a server named sa-forge-signer-debug: the debug build of sa-forge-signer-mcp, pointed at 127.0.0.1:18790. Build with just mcp-build, then leave just mcp-dev running in another terminal: it starts the debug sa-forge-signer-serve on a throwaway home in target/dev (its own config, an unfunded session key named dev, a token and an audit log), creating it on first run. Restart the Claude session after a rebuild; delete target/dev to start over. It is for this checkout only, and never for a real key: the session runs as the user who owns that home, so nothing there is isolated from it.
Code style
The tools enforce most of it: cargo fmt, and clippy with pedantic and nursery denied and the panic rules in Cargo.toml’s [lints] (no unwrap, expect, panic!, indexing or slicing outside tests, no as casts, checked arithmetic, unsafe_code forbidden). The rest is by convention:
- Errors: return
anyhow::Result; add context to every file, network and parse error (.with_context(|| format!("reading {}", path.display()))) so a message names what failed. - Lints: fix the code, for example split a function that is too long, rather than silencing the lint. Where that would make the code worse, or the lint is a false positive or fires on generated code, use
#[expect(lint, reason = "...")]on the one item, never a bare#[allow]. - Control flow:
matchorlet ... elseinstead of checking and then unwrapping; a plainforloop when an iterator chain would be harder to read; an enum instead of aboolparameter whose meaning is not obvious at the call site. - Names: whole words, not abbreviations, except the usual short ones (
cfg,tx,ix). - Modules: each module’s
//!comment says what it owns and is the authority if the code map drifts. Code that mirrors other code (as the console mirrors the audit log) says so and has a test against real output. - Comments: short, for what the code cannot say: why, an invariant, or a dated observation.
- Tests: for every check and every piece of logic; a refusal is tested as well as the pass.
For further reading, Google’s Rust style guide and the Rust API Guidelines are close to this, with two differences: we deny expect as well as unwrap outside tests, and the public interface is the one under Versioning, not a library API.
Commits
Use Conventional Commits: prefix each message with its type, write the rest as a sentence, and end it with a full stop.
feat:a new capability (a check, a command, a config key, a route)fix:a bug fixdocs:,refactor:,test:,build:,ci:,chore:for the rest
Mark a breaking change with ! (feat!:, fix!:) and say what breaks in the body. For example: fix!: refuse a sign whose blockhash is already past the finalized height.
Versioning
The crate follows Semantic Versioning. Its public interface is what a caller or operator depends on, so a change to any of these is breaking:
- the CLI: commands, flags, and their defaults
- the JSON report: field names, types, and
outcomevalues - exit codes
config.toml: keys, their meaning, and their defaults- the payload format the signer accepts
serve: HTTP routes, MCP tools, and auth- the audit log format
So is a check that refuses what was signed before. Adding an optional field, key, or command is not breaking.
While the version is 0.x, a breaking change bumps the minor version (0.4.0 to 0.5.0) and anything else bumps the patch version.
Releases
Releases are made by release-plz from the commit messages. The crate is not published to crates.io (publish = false); release-plz runs in git-only mode, reading the current version from the vX.Y.Z tags. On each push to main, it opens or updates a release PR that bumps the version in Cargo.toml and updates CHANGELOG.md. Review both in that PR; merging it tags vX.Y.Z and creates a GitHub Release. The Release binaries workflow then builds the binaries for that tag, attests them, and attaches them with a SHA256SUMS file to the release. To build them for an existing tag (or rebuild them), run it by hand from the Actions tab with the tag.
Install a release from its tag:
cargo install --locked --git https://github.com/ttdonovan/sa-forge-signer --tag vX.Y.Z sa-forge-signer
Code map
Repository
| Path | Holds |
|---|---|
src/ | The signer: one crate, a library and the binaries built on it, mapped module by module below |
tables/ | The instruction table per cluster (zink-testnet.json), generated by apps/idl-table from the programs’ IDLs, which are not checked in (IDL table). Not part of the crate yet |
config.example.toml | The annotated example config, embedded in the binary and written by config init |
skills/sa-forge-signer/ | The skill that teaches an agent to use the MCP tools (SKILL.md and references/tools.md), embedded in the binaries, served as MCP resources and written by sa-forge-signer-mcp skill install (Resources and the skill) |
apps/ | Extras a human runs beside the signer, each its own crate and workspace, kept out of the signer’s build (Building an app): apps/console/, apps/grant/ and apps/idl-table/ |
docs/ | This book (choices about the signer’s shape are in Design decisions): docs/src/ for chapters, docs/src/dev/ for working on the signer, docs/src/apps/ for the apps (Writing the docs) |
.mcp.json, scripts/dev/ | A Claude Code session’s link to the debug build: the sa-forge-signer-debug MCP server, and the script behind just mcp-dev that runs the debug service on a throwaway home in target/dev (Contributing) |
justfile | Recipes: just check (the gate), plus the docs and apps modules in docs/justfile and apps/justfile (just docs check, just apps console) |
scripts/ci/ | check.sh (the gate run by just check and CI), crate-age.sh (dependency age), docs-check.sh (just docs check), docker-toolchain.sh (the Dockerfile’s Rust matches rust-toolchain.toml) |
scripts/installer/ | install.sh: installs, updates or uninstalls the signer and the console on a host (Install and update) |
scripts/skills/ | gpui-kit.sh: installs GPUI Kit’s Claude skills at the console’s gpui-kit version (GPUI Kit skills) |
scripts/git-review/ | The local review workflow, just mr-open to just mr-approve (Local review with git-review) |
.github/workflows/ | ci.yml (the gate and a Docker build), pages.yml (publishes this book), release-plz.yml (release PRs and tags), release-binaries.yml (attested binaries for each tag) |
Dockerfile | The container image (Running in Docker) |
PLAN.md, CHANGELOG.md, CONTRIBUTING.md, SECURITY.md | Included in this book as Plan, Changelog, Contributing and Security |
Modules
One crate: a library (lib.rs, not a public API) that each binary calls into. Each module’s //! comment is the authority if this page drifts.
| Module | Owns |
|---|---|
main.rs | The sa-forge-signer binary: only calls cli::main |
cli.rs | The CLI (check, sign, halt, resume, key, token, audit, config) and exit codes |
bin/serve.rs | The sa-forge-signer-serve binary: only calls serve_cli::main |
serve_cli.rs | sa-forge-signer-serve’s command line (--config) |
bin/mcp.rs | The sa-forge-signer-mcp binary: only calls mcp_cli::main |
mcp_cli.rs | sa-forge-signer-mcp: MCP on standard input and output, forwarded to a running service’s /mcp with a bearer token; reads no key or config. Also skill print and skill install |
config.rs | Loading and validating config.toml: keys, classes, approval, limits, serve tokens, each key’s policy digest; config init and config show |
cluster.rs | Built-in cluster profiles: genesis hash, default RPC, explorer, and the programs any key may call |
payload.rs | The forge-mcp build_* payload (version 1): an instruction list, not a serialized transaction |
transfer.rs | Funding payloads: a plain System Program transfer in the same shape forge-mcp returns |
checks.rs | Structural checks on the compiled message; none decode game instructions |
policy.rs | Every check check and sign make, as one pure evaluate over the request, the key’s policy and Facts, at a stage (Check, BeforeSimulation, AfterSimulation); returns every result in order. New checks go here |
instruction_table.rs | The hash-checked instruction table from apps/idl-table, and its findings (instructions, accounts, profile) on a compiled message, without decoding anything |
facts.rs | The reads behind evaluate: the halt, the approved policy, the key’s usage from the audit log, and the RPC facts, read one at a time in check order |
engine/mod.rs | The check and sign pipeline and its report: gather facts and evaluate, simulate, the cap gate, the intent, in that order |
engine/fresh.rs | The gates that may rebuild the transaction with a fresh blockhash: approval, the stale-blockhash guard and the duplicate guard |
engine/approval.rs | The confirm prompt at the controlling terminal |
engine/send.rs | Sending, and polling until the outcome is proven or unknown |
engine/tests.rs | The pipeline’s tests, in one file because they share its fakes |
rpc.rs | The minimal Solana JSON-RPC client: only the calls the signer needs |
keystore.rs | The file key store: <key_dir>/<name>.json, 0700 directory, 0600 files; key create, import and destroy |
audit.rs | The append-only, hash-chained JSONL audit log, read back from the end for the checks (recent, walk_back), and the per-key signing lock |
halt.rs | Halts under state_dir/halts/: setting one, resume at the terminal, and the halt_after_unknown trip |
policy_approval.rs | Policy approvals under state_dir/policy/: key approve at the terminal, and the policy refusal |
trace.rs | Opt-in tracing on stderr from SA_FORGE_SIGNER_LOG: the subscriber, and what spans may never carry |
bench.rs | Test-only: just bench, timings of the audit log’s reads on synthetic logs |
serve/mod.rs | serve: the listener and its drain on shutdown, JSON over HTTP at /v1/check, /v1/sign and /v1/halt, bearer-token auth, and the Host and Origin check |
serve/mcp.rs | MCP at /mcp, revision 2026-07-28: the JSON-RPC handler, server/discover, the tools check, sign, key_show and halt, and the resources (MCP conformance) |
serve/mcp_sdk_tests.rs | Tests only: the handler’s messages read back with the Rust MCP SDK’s types |
mcp_wire.rs | What the service and the stdio relay share of that revision: its _meta keys, header names and error codes, and how a header value mirrors the body |
skill.rs | The agent skill built in from skills/sa-forge-signer/: the llms.txt and llms-full.txt texts made from it, and skill install |
url.rs | URL rules shared by the config, the service and the stdio proxy: https, or http only to a loopback host |
text.rs | Caller text (summary, intent, a halt reason): escaped and capped for a terminal, clipped for the audit log |
Checks and gates: just check runs scripts/ci/check.sh (fmt, clippy, tests, shellcheck, advisories, dependency age), the same gate as CI.
Design decisions
Choices about the signer’s shape that are not obvious from the code, each with its reason, what it costs, and what would reopen it. What the signer checks is in How it works, the threats it answers in Security, and work not yet built in Hardening proposals and the Plan. Add an entry here when a change settles a question someone is likely to ask again.
One crate, three binaries by role
Decision (2026-11-11). The crate is a library and three binaries: sa-forge-signer (the operator’s command line and one-shot check and sign), sa-forge-signer-serve (the long-running service) and sa-forge-signer-mcp (MCP on standard input and output). The serve subcommand was removed when the service became its own binary.
Why. The three are run by different parties in different places: a person at a terminal, a service manager or container, and an agent’s MCP client. A binary that does one of those is easier to install, start and reason about than a subcommand of another. One crate keeps one version, one changelog and one release.
Cost. A release carries more than one binary, and the Docker image’s entrypoint is the service, so its command line needs --entrypoint sa-forge-signer (Running in Docker).
The library is not a public API
Decision (2026-11-11). src/lib.rs exists so the binaries share one implementation. Its modules are private; the only public items are each binary’s main.
Why. The interface callers depend on is the command line, the routes and the file formats (Contributing). A public Rust API would be a second interface to keep stable, for no caller that exists.
Reopen when another crate has a real need to link the signer’s logic. The apps mirror a little of it today and are kept in step by tests (Building an app).
The stdio MCP binary is a client of the service, not a second signer
Decision (2026-11-11). sa-forge-signer-mcp forwards each MCP message to a running service’s /mcp route with a bearer token. It reads no key and no config and cannot sign on its own.
Why. An MCP client starts a stdio server as a child process, so it runs as the agent’s OS user. The isolation rule is that this user cannot read the keys or the config. A stdio server with the engine inside it would need both, and a confirm prompt would land on the agent’s own terminal. As a client it holds only what the HTTP setup already gives the agent: the service’s URL and a token.
Cost. stdio needs the service running; there is no single-process MCP setup.
Reopen when never for keys of value. An in-process mode could only suit the setup where the agent already holds a throwaway testnet wallet and runs the command line as itself, and it would have to refuse confirm keys.
The service is synchronous: tiny_http and a hand-written MCP handler
Decision (2026-11-11, confirming how it was first built). The service is a thread-per-request HTTP server on tiny_http, and src/serve/mcp.rs handles MCP’s JSON-RPC by hand: server/discover, the tools and the resources, with JSON replies and no streaming. The binaries do not use rmcp, the Rust MCP SDK; it is a dependency of the tests only, where its types check the handler’s messages (MCP conformance).
rmcp does provide both a stdio and a streamable HTTP transport. That does not change the entry above: whichever library speaks the protocol, a stdio server still runs as the agent’s user, so the stdio binary would still be a client of the service.
Why.
- The engine blocks from end to end: RPC calls, file locks, a terminal prompt, and a wait of up to 150 s for an outcome. A thread per request fits that as it is. Under an async server every tool call would have to be moved onto a blocking thread.
- The service also has plain HTTP routes (
/v1/check,/v1/sign,/v1/halt,/health) that an MCP SDK does not provide, so an HTTP framework would be needed beside it. - No async runtime keeps the dependency tree small in the one process that holds the keys.
rmcp’s HTTP server bringstokioand an HTTP stack with it; how many crates that adds has not been measured. - Each new dependency is subject to the 7-day age rule and to
cargo auditin the gate.
Cost. MCP features have to be written by hand as they are needed, and tool schemas are written out rather than derived from types. The test-only rmcp adds about 30 crates to the lockfile (tokio and chrono among them), all subject to the gate’s age and advisory checks though none reaches a binary. The handler does not stream, so a client sees nothing between calling sign and its report.
Reopen when a feature is wanted that the hand-written handler cannot reasonably grow, most likely progress messages during a long sign. Moving the service to rmcp then means an async HTTP framework for the other routes, the token, Host and Origin checks as middleware, and the shutdown drain rebuilt. The MCP code is in one file so that change stays contained. What that file implements of the specification, and what it does not, is in MCP conformance.
One MCP revision, the latest
Decision (2026-11-11). The signer speaks MCP 2026-07-28 and no earlier revision.
Why. That revision is stateless: no handshake, no session, each request complete in itself. That is how the hand-written handler already worked, and it removes the parts a second revision would have forced it to keep (initialize, ping, version negotiation, two sets of error codes). Supporting both eras means carrying both behaviours in a handler with no SDK behind it.
Cost. A client that speaks only an earlier revision cannot connect; it gets an error naming 2026-07-28 (MCP conformance).
Reopen when a client that matters cannot be upgraded. The specification allows one server to serve both eras on the same endpoint.
Agent guidance ships in the binary
Decision (2026-11-11). The service serves llms.txt, llms-full.txt and a skill as MCP resources, and sa-forge-signer-mcp skill install writes the same skill to disk (Resources and the skill). The text is skills/sa-forge-signer/, built in at compile time.
Why. Tool descriptions alone do not tell an agent what to do after unknown, which refusals it cannot fix, or when to halt. Building the files in means what is served, what is installed and what the code does are one version.
Cost. The guides are static: they do not list the keys a token may use (key_show answers that), and a change to the report or the tools has to update skills/sa-forge-signer/ in the same change. A test fails if the guides and the tool list name different tools.
Apps stay out of the signer’s gate
Decision (standing rule; its workflow added 2026-11-10). Each app under apps/ is its own crate, workspace and lockfile, outside just check, the ci workflow, the release binaries and the Docker image. A separate apps workflow runs the grant page’s and the IDL table generator’s own gates (Building an app).
Why. The apps pull in large dependency trees (a UI framework, a wasm toolchain) that should neither slow nor widen the gate of the binary that holds keys.
Cost. The console’s gate runs only locally, and an app can break without failing the signer’s required checks.
Reopen when the console’s build requirements on a CI runner are known, to add it to the apps workflow.
MCP conformance
The signer speaks the Model Context Protocol with a handler written by hand (src/serve/mcp.rs), not an SDK (Design decisions says why). This page is the record of what it implements, checked against the specification’s pages on 2026-11-11. The Rust MCP SDK (rmcp) is a test-only dependency that checks the handler’s messages (Checked by the SDK).
The specification to read is the one at modelcontextprotocol.io, which is built from the modelcontextprotocol/specification repository. Other sites reproduce it; when they differ, that one is right. Each revision has its own tree, such as https://modelcontextprotocol.io/specification/2026-07-28/, and its TypeScript schema is the source of truth for message shapes.
Revision
The signer speaks 2026-07-28 and nothing earlier. That revision is stateless: there is no initialize handshake and no session, and every request carries its protocol version and the client’s capabilities in params._meta.
A client of an earlier revision cannot use the signer. Its initialize is answered with error -32022 (unsupported protocol version) naming 2026-07-28, on HTTP with status 400, so the client can say why. The specification gives such a client no way to move forward on its own. Claude Code speaks 2026-07-28 by default from version 2.1.274, by its own documentation.
Scope
The service is an MCP server with two features: tools and resources. It offers no prompts, completions, subscriptions, tasks or extensions, never asks the client for input (every result is complete, never input_required), and sends no notifications. Its lists are short and fixed, so they are never paginated and never change.
| Method | Answer |
|---|---|
server/discover | supportedVersions (2026-07-28), capabilities (tools, resources), and instructions |
tools/list | check, sign, key_show and halt, each with an inputSchema, always in that order |
tools/call | One text content block and isError (Serving over HTTP and MCP) |
resources/list, resources/read | Four static texts (Resources and the skill) |
| any notification | Accepted and ignored |
initialize | Error -32022, as above |
anything else, ping included | Error -32601, method not found, on HTTP with status 404 |
Every result carries resultType: "complete" and names the server in _meta["io.modelcontextprotocol/serverInfo"]. The discovery result, both lists and a resource read also carry the caching hints the revision requires: ttlMs of 300000 and cacheScope private, since every answer is behind a bearer token.
A request
| Rule | The service |
|---|---|
A request’s id is a string or an integer, never null | Anything else is error -32600, status 400 |
params._meta carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities; a request without them is error -32602, status 400 | Yes. The capabilities must be an object; the service needs none, so it reads no further |
A version the server does not implement is error -32022 with data.supported and data.requested, status 400 | Yes |
| A server must not rely on earlier requests for context | The handler keeps no state between requests |
The HTTP transport
POST /mcp on sa-forge-signer-serve follows the Streamable HTTP transport, in its plain form: one JSON object per reply, no event stream.
| Rule | The service |
|---|---|
A request is answered with Content-Type: application/json or an event stream | Always application/json |
An accepted notification gets 202 Accepted with no body | Yes |
Every POST carries MCP-Protocol-Version, matching the body’s _meta; a missing or different one is error -32020 (header mismatch), status 400 | Yes |
Every request carries Mcp-Method, matching the body’s method | Yes: missing or different is -32020, status 400 |
tools/call and resources/read carry Mcp-Name, matching params.name or params.uri | Yes: missing or different is -32020, status 400. A value sent as =?base64?...?= is decoded before it is compared |
A method the server does not implement is 404 Not Found with error -32601 | Yes |
A server of this revision alone answers GET and DELETE with 405 Method Not Allowed | Yes |
The Origin header must be validated, and an invalid one answered with 403 Forbidden | Yes: Origin and Host must name an entry in allowed_hosts |
Mcp-Session-Id and Last-Event-ID are ignored | They are never read |
The checks run in this order: the body is JSON and one message, initialize, the _meta fields, the headers against the body, then the version. Authentication is outside the specification’s own scheme: the service takes a bearer token from its config, not OAuth. A request without a known token is 401 before the body is read, with a plain JSON body, not a JSON-RPC error.
Tool arguments are not mirrored into Mcp-Param-* headers: no tool marks a parameter with x-mcp-header.
Tools and resources
| Rule | The service |
|---|---|
A tool that ran and failed reports it in its result with isError: true; a protocol problem is a JSON-RPC error | A report whose outcome is not ok or confirmed, a refused key and a failed halt are results with isError: true |
An unknown tool is error -32602 | Yes |
| A server with tools or resources declares the capability | Both declared, as empty objects: no listChanged, no subscribe |
A resource that does not exist is error -32602; -32002 must not be sent | -32602 with the uri in data |
A listed resource has uri and name | uri, name, description and mimeType |
The stdio transport
sa-forge-signer-mcp is the stdio side. It is not a server of its own: it relays each message to the service above, so the methods and the rules are the service’s.
| Rule | The binary |
|---|---|
| Messages are JSON-RPC, one per line, with no newline inside a message | Each reply is written as one line, whatever the service’s formatting |
| Standard output carries only protocol messages | Yes: anything else goes to standard error |
| A notification gets no reply | None is written, also when the service cannot be reached |
On stdio the request metadata is only in _meta; there are no headers | The binary builds the three HTTP headers from each message’s body before forwarding it |
A client probing with server/discover gets a discovery result or a recognised error | The service’s answer is passed through, as is its -32022 for an initialize |
When the service cannot be reached, or answers outside JSON-RPC (a wrong token is HTTP 401), the binary writes a JSON-RPC error with code -32603 for that request’s id. A line that is not JSON gets -32700 with no id.
Checked by the SDK
src/serve/mcp_sdk_tests.rs reads the handler’s messages back with the types of rmcp, the Rust MCP SDK, pinned to one version as a dev-dependency. The binaries do not link it. The tests check that:
- the requests the tests send parse as the SDK’s client requests, so they model a real client;
- the results of
server/discover,tools/list,tools/call,resources/listandresources/readparse as the SDK’s types for them, withresultType, the caching hints and the server’s name where the SDK reads them; - each error parses as the SDK’s error with the code the SDK defines for it, and the unsupported-version error equals the one the SDK itself builds.
Not checked
- The SDK’s types are looser than the specification in places: fields the
2026-07-28schema requires, such asresultTypeon a list, are optional in the SDK because it also models earlier revisions. The tests assert those fields themselves. - No MCP client has been run against a live service. The SDK’s client is not used: it would bring an HTTP stack into the tests for little beyond what the type checks give. The tests in
src/serve/andsrc/mcp_cli.rssend requests built by hand. - The specification’s TypeScript schema was read only in part; the pages for the base protocol, versioning, discovery, both transports, tools, resources, pagination and the changelog were read in full.
Keeping this page true
A change to src/serve/mcp.rs, src/mcp_wire.rs or src/mcp_cli.rs that adds a method, a header, a status or an error code updates this page and src/serve/mcp_sdk_tests.rs in the same change. When rmcp is bumped, a test that stops passing is a difference between the handler and the SDK’s reading of the specification: find out which is right before changing either. Before supporting a new revision, read its changelog page first: it lists what was removed as well as what was added.
Plan
Work tracked in this repository. Update it in the same change as the work it tracks.
Next
- Cargo’s native minimum publish age (not before 2026-11-19). Rust 1.100 (due 2026-11-12) stabilises
registry.global-min-publish-age; on 1.98 the key only warns. Wait until the release is itself at least 7 days old (later if a point release follows), then:- bump
rust-toolchain.tomlto 1.100.x, and theDockerfile’sRUST_IMAGEtag and digest to match (scripts/ci/docker-toolchain.shfails until they agree); - add
.cargo/config.tomlwith[registry]global-min-publish-age = "7 days"; - remove
scripts/ci/crate-age.shand its line inscripts/ci/check.sh, and drop “dependency age” from thecheckrecipe comment in the justfile (Cargo enforces it at resolve time instead); - confirm
cargo updatethen respects the age andjust checkand CI still pass.
- bump
Backlog
- Instruction table: enforce mode (high): report mode is built (
instruction_table = { path, sha256, mode = "report" }, findingsinstructions,accountsandprofilein reports and audit entries). Remaining:mode = "enforce", which refuses on any finding (feat!:, since it refuses what was signed before), after report mode has run against real play long enough to show the table and forge-mcp’s payloads agree (Hardening proposals, 4). - Redeploy watch and holds (low; high before mainnet): read each game program’s last-deploy slot from its program-data account; on a change, write a
program-redeployaudit entry and, withon_program_redeploy = "hold", refuseautosigns per instruction family untilprograms acceptor a passingcanaryrun overcanary_dir(payloads with expected outcomes, where an unexpected success fails and an RPC error releases nothing) (Hardening proposals, 8). - Reserved headroom for recovery (optional, after the instruction table):
reserved_per_minuteandreserved_lamportskept for messages whose game instructions are all inreserved_families, classified from the table, never fromsummary(Hardening proposals, 9). - Audit store: the log is one JSONL file that only grows. Signs now read only its end and the console only what was appended (with a full read at most once a minute), but
audit verifyand the console’s start-up and minutely check still read it all, and its size is unbounded. Revisit when a realaudit.jsonlpasses 100 MB (roughly 100,000 signs) oraudit verifytakes more than 10 s. Decide between rotation (daily segments, the hash chain carried across files,audit verifyand the console reading all segments) and a store with an index, keeping the chain’s guarantees and the repair procedure inSECURITY.md. - RPC latency (optional, when a trace shows the need): a sign through
servetakes about 1.7 s againstrpc1.z.ink, almost all RPC round trips. Candidates, each with the trace symptom that would justify it: a keep-alive call whileserveis idle if the server drops connections sooner than 2 minutes; one retry of a read after a transport error on a reused connection; more idle connections per host for parallel signs; JSON-RPC batches for the five reads before sending, if the RPC accepts them; poll-schedule tuning under congestion (RPC calls and latency). - Approval outside the terminal:
confirmreads/dev/tty, which an agent running as the terminal’s user can type into through a multiplexer or a terminal app’s scripting API, and whichservein a container usually lacks. Move approval to a channel that checks who answers, such as a Unix socket that accepts only a configured approver uid (peer credentials), soconfirmkeys also work underserve. servethread cap and RPC budgets: one thread per request is unbounded today; cap concurrent requests and answer the rest with 503.checkhas no rate limit either: add[serve] check_per_minuteper token (429) and keep polling of already-sent transactions ahead ofcheckand preflight RPC calls; the genesis hash is already cached per process (Hardening proposals, 10).- MCP: drive
/mcpwith a real client in tests (optional): the handler’s messages are checked against the Rust MCP SDK’s types (rmcp, a dev-only dependency), but no client has been run against a live service. The SDK’s own client would do it at the cost of an HTTP stack in the test build (MCP conformance). - MCP: progress during a long
sign(optional): the2026-07-28revision lets a server answer a request with an event stream and send progress on it. The service answers with one JSON object, so a client sees nothing until the report. Needs streaming, which the hand-written handler does not do (Design decisions). - SPL Token authority check: refuse top-level SPL Token
Transfer/Approve(and theirCheckedforms) whose authority is the signing key, using the public SPL Token instruction layout. Today such instructions pass on program id alone, so a hand-written payload could move tokens from a token account the key owns. - Key stores: an encrypted-file backend (age, passphrase at
servestart) and macOS Keychain / Secret Service backends, for running outside a container. Check first whether a Keychain item can be isolated from other processes of the same user. For age: write files at a scrypt work factor of at least 2^18 and refuse to load one below it; use age rather than an own Argon2id envelope; take the passphrase from the terminal or systemd credentials, never an environment variable or argument (Hardening proposals, 11). - Read-after-write (optional): document the report’s
slotas the minimum context slot for the caller’s next reads, and pass the key’s last landed slot asminContextSloton the signer’s own pre-sign reads (Hardening proposals, 12). - systemd unit (low): a chapter with an example hardened unit (dedicated user,
ProtectSystem=strict, empty capabilities,SystemCallFilter=@system-service,LimitCORE=0,MemorySwapMax=0,TimeoutStopSec=230,LoadCredentialEncrypted=for the key passphrase) checked withsystemd-analyze security(Hardening proposals, 13). - Secrets in memory:
Keypairalready zeroes itself on drop (solana-ed25519’s defaultzeroizefeature), and key files, payload text and partial signers are read intoZeroizingbuffers. What is left: buffers that grow while being filled (serde_jsonparsing a key file into aVec<u8>,read_to_stringfrom stdin or an HTTP body) free copies that are never wiped, so reserve the full size up front;serve’s MCP route parses the request into aserde_json::Valuethat holdspartial_signersand is dropped without wiping; and moves of aKeypairleave copies on the stack. Ahead of those, guard against copies outside the process: turn off core dumps (RLIMIT_CORE0, andPR_SET_DUMPABLE0 on Linux) and considermlockfor key pages so they are not swapped out. Low priority: all of this assumes a memory disclosure in a process the agent’s OS user cannot read. - Local-validator integration tests: prove the outcomes that only fake-chain unit tests cover today:
failed(exit 12, landed with a program error), an expired blockhash with no status found (unknown, exit 14; exit 13 is retired), and the one-time re-sign onBlockhashNotFound. - Releases: reproducible builds (verify two builds match byte for byte), an SBOM attested next to the provenance, and an external review before any mainnet use. Checksums and build provenance attestations ship with each release since v0.4.0.
Hardening proposals
Proposals for the signer. A proposal that has shipped says Status: built and how the build differs from the proposal, if at all; the rest are not built. Each names the threat it addresses (in the terms of Security), what changes in src/, the config keys, refusals and audit fields it adds, how it is tested, its priority, and how it interacts with the others. Priorities: blocker (fix before relying on the signer), high (a real gap at low cost), low (worth doing when nearby work touches it), optional (only if a need appears). There are no blockers. Each proposal has a line in the Plan’s backlog; this chapter holds the reasoning.
Three facts frame them:
- The signer is thin on purpose. It checks what the chain does not, does not decode game instructions, and needs no IDL (How it works). Proposal 4 adds an opt-in table of each instruction’s shape (discriminator, account count, profile slots), generated from the IDLs into its own data file; it still decodes nothing.
- It is non-custodial for anything of value. In Path B the human’s wallet holds the profile; the signer holds a narrow, expiring session key (Choosing a setup). Nothing here makes it easier to hold the profile authority unattended.
- It is testnet-only and agent-facing. The requester is an agent that can be prompt-injected, so every field a payload or its
summarysupplies is untrusted. Checks key on the compiled message, configured state, and what the signer reads from the chain itself.
A check that refuses what was signed before is a breaking change (Versioning). Proposals that add one say so, and ship first as a report-only mode where that is practical.
Build order
flowchart TD
D["2: Duplicate-signature guard"]
H["3: Halt"]
P["1: One policy function"]
C["5: Compute budget and transfer limits"]
T["4: Instruction table and profile binding"]
DG["7: Policy digest"]
U["8: Redeploy watch and holds"]
R["9: Reserved headroom"]
B["10: RPC budgets in serve"]
P --> C
P --> T
T --> U
T --> R
P --> DG
H --> U
- Proposals 2 and 3 first: small, independent, and they close a wrong outcome and a missing control.
- Proposal 1 next: a refactor with no behaviour change except
check’s preview, and the base the later checks plug into. - Then 5, then 4 in report mode, then 4 enforced.
- Then 7, 8, 9 and 10 as the need appears; 11 to 13 whenever convenient.
1. One policy function for check and sign
Threat: a clean check that sign then refuses (the rate limit, the daily cap, a deny key, approval), so an agent plans on a payload it cannot send; and checks that are hard to test because they interleave with RPC and audit-log reads.
Today: checks::structural is already pure over the message and a Policy. The new-accounts and token-account-owner checks, the limits and approval are not: they read the RPC or the audit log in the middle of engine::preflight.
Status: built. How it differs from the proposal below:
checkdoes not refuse adenykey: it previews it asapprovalinsign_preview, and itsoutcomestaysok. That keeps this change non-breaking; refusing it incheckremains open.- The facts step reads the RPC one fact at a time in check order (genesis hash, then partial signers, then token-account owners), and
evaluateruns after each read. A refusal is decided only when every check before it has passed, so a refusal decided without the RPC (a wrong fee payer, adenykey, a halt) is still made before any RPC call, as before.Factsholds the key’s usage (signs in the last minute, lamports spent or reserved in 24 h) already counted from the audit log at the time it was read, soevaluateneeds no clock. check’s preview of the cap usesAfterSimulationwithcheck’s own simulated spend and fee once its simulation passes, so it matches whatsignwould compute for the same transaction.- The approval prompt, the stale-blockhash and duplicate-signature guards stay in the engine: they rebuild the transaction rather than judge it. After them,
signrefreshes the usage from the audit log and evaluates again atAfterSimulation. checknow reads the audit log and the halt record, for the preview: an unreadable audit log fails acheckas it already failed asign.
Change:
- A
policymodule with one function,evaluate(message, &Facts, &KeyPolicy, Stage) -> Verdict.Factsholds everything read from outside, gathered first: the owners of token-account owners, whether partial signers exist, the key’s signs in the last minute and lamports spent or reserved in 24 h (from the audit log), the halt state (proposal 3), and later program deploy slots (proposal 8).StageisCheck,BeforeSimulationorAfterSimulation(with the simulated spend and fee for the cap). - A
factsstep does all the I/O.evaluatedoes none, reads no clock (the time is a field ofFacts), and returns every failed check in order, not only the first. checkevaluates the sign-only gates too and reports them in a newsign_previewfield: the checkssignwould refuse now, andneeds_approval: truefor aconfirmkey.check’s outcome staysokwhen only sign-only gates fail, so this part is not breaking. Refusing adenykey incheck, as the backlog item proposes, is breaking: ship it with afeat!:commit.signcalls the same function before and after simulation, so the two paths cannot drift.
Tests: table-driven tests of evaluate with no RPC and no files, one row per check and its refusal; a test that check and sign give the same verdict on the same Facts; the existing engine tests unchanged.
Priority: high, and first among the larger items. Interactions: it is where proposals 4 to 9 add their checks; it supersedes the backlog’s “check previews the sign-only checks”.
2. Duplicate-signature guard
Threat: ed25519 signatures are deterministic, so the same instructions with the same blockhash give the same signature. If an agent sends the same payload twice (two identical cargo moves, say) and the RPC returns the same latest blockhash both times, the second send is the first transaction again: the cluster drops it as a duplicate, its status lookup finds the first one, and the signer reports confirmed for an action that happened once. Nothing in engine.rs checks for this today.
Status: built. The guard runs just before the cap gate rather than after it, so the cap is checked once, on the transaction actually sent; the outcome is the same. A repeat is detected by signature, which also covers the case where the RPC returns the same blockhash again.
Change: after the cap gate and before the intent is recorded, under the key lock, look up the transaction’s signature among this key’s audit entries from the last 24 h (intents and final entries). If it is there, fetch a fresh blockhash and re-simulate once, as the BlockhashNotFound path already does, and re-run the cap gate on the new figures. If the RPC still returns the same blockhash, refuse with failed_check blockhash and a detail saying nothing was sent and a retry is safe. Add duplicate to checks when the guard passes.
Tests: a fake chain that returns one blockhash for two identical payloads: the second sign gets a new blockhash and a different signature; a fake chain that can only return that blockhash: the second sign is refused and nothing is sent; a confirm key whose approval rebuild already changed the blockhash passes untouched.
Priority: high: the false outcome is the kind the retry rules exist to prevent. Interactions: the approval rebuild and the one-time re-sign on BlockhashNotFound; the audit log is the lookup, so CLI and serve signs share it.
3. Halt
Threat: under serve, the only ways to stop one key are editing approval = "deny" and restarting, or stopping the service with its 230 s drain. An operator who sees something wrong needs a stop that takes effect on the next request and does not lift itself.
Status: built. How it differs from the proposal below:
checkreports a halt in asign_previewfield with arefusalslist, added here ahead of proposal 1, which adds the other sign-only gates andneeds_approvalto it.halt_after_unknownis a per-key setting (default 3,0never). A landed outcome (confirmedorfailed) or aresumeresets the count; refused and failed-simulation signs neither count nor reset it. The default refuses signs that were allowed before, so it shipped as a breaking change.- A token’s halt without
keywrites one halt per key the token lists, never the halt on every key. It does not wait for the key lock, so a sign already running finishes. resumewithout--keylifts only the halt on every key; each key’s own halt needsresume --key. Audit entries for the halt on every key usekey*.
Change:
sa-forge-signer halt [--key <name>] --reason <text>writes a halt record understate_dir(all keys, or one) and appends ahaltentry to the audit log. Everysignreads it under the key lock (it is a field ofFacts) and refuses withfailed_checkhaltand the reason.checkstill runs and lists it insign_preview.sa-forge-signer resume [--key <name>]removes it, with a/dev/ttyconfirmation likeconfirmand aresumeaudit entry. Nothing resumes automatically, andservehas no route that resumes.- An optional MCP and HTTP
halt(no resume) lets an agent stop its own key when it notices it is misbehaving; a token may halt only the keys it lists. - Automatic trips, latched the same way:
halt_after_unknown = 3(consecutiveunknownoutcomes for a key, a sign that the RPC or the transactions are in trouble), and proposal 8’s hold.
Tests: a halted key refuses and an unhalted one signs; a halt written by the CLI is seen by a running serve on its next request; resume without a terminal refuses; the third consecutive unknown trips the halt and a confirmed between them resets the count; the halt and resume entries chain in the audit log.
Priority: high. Interactions: with proposal 9, a halt could leave the reserved families open (--except recovery) so a stop never strands a fleet; until then a halt stops everything for that key. With the “Approval outside the terminal” item, resume moves to the approver channel.
4. Instruction table and profile binding
Threat: the programs check looks only at program ids, so any instruction of SAGE, Player Profile or Profile Faction passes, including ones no builder emits and ones whose accounts are malformed. Inside those programs the chain checks that the signer is the key at the given index of the given profile, so a wrong index already fails in simulation; but a key that is a key of several profiles (a wallet key that is the authority on more than one, for example) can act for any of them, and nothing holds it to the one the operator meant.
Status: report mode built; enforce mode is not (mode = "enforce" does not load yet). How the build differs from the proposal below: the table’s path resolves against the config’s directory, like key_dir; only top-level instructions are compared, since the signer sees no inner calls; an instruction with the wrong account count gets that finding and no profile finding; and the audit entry’s findings field is left out when empty, so entries written before it keep their hashes (the console mirrors the field).
Decoder-free: every check here compares the compiled message with the table and the config. The signer reads no profile account and decodes no account data, so this proposal adds no RPC call and no account layout to follow across redeploys. What only a decoded profile could check is under “Not in this proposal” below.
Change:
- An instruction table, a data file per cluster: for each instruction of SAGE, Player Profile and Profile Faction, keyed by program and discriminator, its name, the minimum and maximum account count, and its profile groups: each pair of a signer slot and the profile slot the program checks it against, with where the key index sits in the instruction data. Families (
movement,cargo,recovery, …) are added when proposal 9 needs them. - Config:
instruction_table = { path = "...", sha256 = "...", mode = "report" }at the top level. The signer refuses to start if the file’s hash differs, so a swapped table cannot widen anything.mode = "report"adds findings to the report and audit entry without refusing;"enforce"refuses.keys.<name>.profile, informational today, becomes binding when a table is configured. - Checks, as named refusals:
instructions(an unknown discriminator: unknown means refuse),accounts(too few or too many), andprofile(a profile group whose signer slot holds the signing key has a profile slot other thankeys.<name>.profile, or the key has noprofileset). Which group is the key’s own is decided per payload, by which signer slot holds the key, so the table needs no hand-written part. - Where the table comes from: generated from the programs’ Codama IDLs by
apps/idl-tableintotables/<cluster>.json, holding only per-instruction facts and each IDL’s sha256, so the IDLs are not checked in (IDL table). The IDLs are not published on Z.ink testnet; today they come from a sage-forge checkout. sage-forge’s builders know each instruction’s account order, so sage-forge could publish this table, versioned and hashed, from the same source as itsbuild_*methods. That is an option, not a dependency: the signer reads a file, whoever wrote it.
Paying slots, dropped: an earlier version of this proposal also bound the slots a key pays for to its own profile. In the IDLs, instructions that check a profile pay from the profile itself; only instructions with a funder signer take the key’s lamports, and those are cranks anyone may run (respawn completion, rent, eviction, epoch rolls), where the sender paying for someone else’s accounts is the design. Binding their profile slot would refuse legitimate cranks. What a key spends on them is bounded by its daily cap, and the table keeps the funder slot as a fact.
Not in this proposal: reading and decoding the key’s profile account with the layout apps/grant/src/profile.rs has. That would add a key_index check (the index is past the profile’s key list, the entry is not the signing key, or it has expired), key show’s on-chain grant, and an optional max_permissions that refuses a grant wider than the operator meant to give. The chain already checks the signer against the profile at the given index, so key_index would mostly turn a failed simulation into an earlier refusal; max_permissions is the one new protection, and it can be proposed on its own if a need appears. The table keeps each key index’s location, so that proposal would need no new table format.
What it costs: the signer stops being IDL-free for these fields, and the table must follow every redeploy that adds or reorders instructions; in enforce mode a new instruction is refused until the table lists it. That is the intended failure direction, and proposal 8 tells the operator when to look.
Tests: per rule, a passing message and a finding; a table whose hash does not match refuses to load; report mode never refuses; a key without profile gets a finding on each of its profile groups; a group whose signer is another key is not bound.
Priority: high, in two steps: report mode (not breaking), then enforce mode (feat!:, since it refuses what was signed before). Interactions: built on proposal 1; proposals 8 and 9 use the table.
5. Compute budget and System transfer limits
Threat: ComputeBudget instructions pass on program id alone, so a payload can carry SetComputeUnitPrice at any price, RequestHeapFrame, or a loaded-accounts limit. The cap gate counts the fee, so the loss is bounded by the daily cap, but one payload can spend all of it as a priority fee. Transfers go only to allowed destinations, but in any amount up to the cap.
Status: built, as below, with one addition: a System Transfer must be exactly its tag and amount (12 bytes), so the amount summed is the amount the runtime moves. Refusing priced payloads, other ComputeBudget forms and transfers from partial signers shipped as a breaking change.
Change:
- ComputeBudget: allow only
SetComputeUnitLimitup tomax_compute_units(default 1,400,000, the runtime’s maximum) andSetComputeUnitPriceup tomax_compute_unit_price(micro-lamports, default 0, so refused), each at most once, with no accounts and exact data lengths. Refuse every other ComputeBudget instruction (compute_budget). forge-mcp’s builders emit onlySetComputeUnitLimit, so its payloads keep passing. - System: an optional
keys.<name>.transfer_max_lamports, the sum of all Transfers in one transaction, refused assystemwhen exceeded; the source of each Transfer must be the signing key.
Tests: each ComputeBudget form and length, at and over each ceiling; two limits in one message; transfers summed across instructions; a transfer from a partial signer.
Priority: low: bounded by the cap today, but cheap. Refusing a priced payload is breaking (feat!:). Interactions: lives in proposal 1’s evaluate.
6. Grant policy for wallet keys
Threat: Security says the signer does not decode what a profile grant gives away, which is why a wallet key needs confirm unless allow_unattended is set. A prompt-injected agent with an unattended wallet key can add its own key with every permission.
Status: dropped. Checking a grant means decoding the arguments of Player Profile AddKeys and RemoveKeys (the new key, its permissions, its expiry), and the signer stays decoder-free (proposal 4). The gap stays in Security: a wallet key needs confirm unless allow_unattended is set, and the profile authority of anything of value stays in the human’s wallet (Path B).
7. Policy digest
Threat: a change to a key’s policy (its limits, destinations, extra programs, the tokens that may use it) takes effect silently at the next start, and the audit log does not record which policy a signature was made under.
Status: built. How the build differs from the proposal: the digest covers each resolved key field (including require_policy_approval), the name and sha256 of each serve token that lists the key, the cluster, and the instruction table’s sha256 and mode; the RPC URL, the explorer, the directories and serve’s address and hosts are left out, since they do not change what the key may sign. The policy field is on sign, sign-intent and approve entries; halt and resume entries carry none, since nothing is signed under them. The approval is a file under state_dir/policy/ plus an approve audit entry, and an unreadable file refuses rather than passes. The policy refusal is sign-only, so check lists it in sign_preview. key approve on a key without require_policy_approval still records the approval and says the key does not wait for it. The MCP key_show tool also returns the digest, whether it is approved, and the key’s halt, so an agent can see that a key will not sign before it builds a payload.
Change:
- A digest per key: sha256 of a canonical form of the key’s effective policy (every resolved
KeyConfigfield, the tokens that list it, the cluster, the instruction table’s hash).config showandkey showprint it. - Every audit entry gains an optional
policyfield with that digest. Adding an optional field is not breaking. - Optional
keys.<name>.require_policy_approval = true: the key signs only while its digest matches the one last approved withsa-forge-signer key approve <name>at the terminal (recorded understate_dirand in the audit log). Any change to the key’s policy refuses (policy) until approved again.
What it does not do: the same OS user writes the config and state_dir, so this is a deliberate second step and a record, not a barrier against that user.
Tests: the digest is stable under reordering of lists and changes with each field; a changed config refuses until approved; approve without a terminal refuses.
Priority: low (the audit field is cheap; the approval step is optional). Interactions: the “Approval outside the terminal” item’s approver channel can approve digests too; proposal 1 makes the digest one of the Facts.
8. Redeploy watch and holds
Threat: a redeploy of a game program can change what an instruction does while simulation still passes: a check dropped, a cost changed, accounts reordered so that proposal 4’s slots mean something else. Unattended keys keep signing through it.
Change:
- For each built-in game program, read its program account and the last-deploy slot in its program-data account (the public upgradeable-loader layout), with a data slice of a few bytes. Before each sign, or at most every 30 s under
serve. The last seen slot per program is kept understate_dir. - On a change: an audit entry
program-redeploy, andon_program_redeploy = "note" | "hold"(default"note", since testnet redeploys often)."note"adds it to reports and toconfirmprompts."hold"refusesautosigns (program_redeploy) until released, scoped to the table’s families when proposal 4 exists, else the whole key. - Release:
sa-forge-signer programs acceptat the terminal, or a passing canary run.canary_dirholds payloads, each with the outcome it should have:ok, or a named program error for a payload meant to fail.sa-forge-signer canaryrunscheckon each; a payload that should fail but now succeeds is a failure (the redeploy dropped a check), and an RPC or missing-account error is inconclusive and releases nothing. Only a full pass releases a family.
Tests: a fake program-data account whose slot changes; hold and note modes; a canary set with a pass, an expected failure, an unexpected success, and an inconclusive result; holds scoped per family.
Priority: low for testnet; high before any mainnet profile. Interactions: proposal 3’s latch; proposal 4’s table must be regenerated after a redeploy, and the hold is the cue.
9. Reserved headroom for recovery
Threat: when an agent has spent its rate limit or most of its daily cap, the signs that would get a fleet out of trouble (dock, stop mining, warp away, revoke a key) are refused with everything else.
Change: keys.<name>.reserved_per_minute and reserved_lamports: ordinary signs are refused once fewer than that many signs, or lamports, remain; signs whose every game instruction is in a reserved family (reserved_families = ["recovery"]) may use the rest. The family comes only from proposal 4’s table, never from the payload’s summary.
Tests: an ordinary sign refused inside the reserve, a recovery sign passing there; a message mixing a recovery and an ordinary instruction counts as ordinary.
Priority: optional. Interactions: needs proposal 4; proposal 3’s halt can exempt the same families.
10. RPC budgets in serve
Threat: check has no rate limit and makes several RPC calls. A token holder that loops on it can get the RPC to throttle the signer, which delays the confirmation polls of transactions already sent and turns them into unknown.
Change: [serve] check_per_minute per token (a sliding minute, in memory; answered with 429), and RPC calls in three classes, with polling of already-sent transactions never waiting behind check or preflight reads. The genesis hash is cached per process and re-read every few minutes (built: every 5 minutes, per RPC URL, along with one connection pool per URL).
Tests: the 429 and its reset; a burst of checks does not delay a poll in a fake RPC with a call budget.
Priority: low. Interactions: extends the backlog’s “serve thread cap”.
11. Encrypted key files: the KDF
The backlog’s key-store item proposes an age-encrypted file with a passphrase given at serve start. age’s passphrase mode uses scrypt with a work factor stored in the file; the signer should create files at a work factor of at least 2^18 and refuse to load one below it. Argon2id is the stronger memory-hard choice, but only as part of a new file format, and a hand-made envelope is more risk than a reviewed one: use age. The passphrase comes from the terminal or from systemd credentials (proposal 13), never from an environment variable or argument. load_key already checks the decrypted key’s pubkey against the config. This is for session keys and testnet wallet keys; the profile authority of anything of value stays in the human’s wallet (Path B), and the grant page is how it delegates.
12. Read-after-write
The report’s slot is the slot the transaction landed in. Document it as the minimum context slot for the caller’s next reads, so an agent re-reading state through sage-forge does not act on a node that has not seen its own transaction yet. The signer’s own reads before a sign (partial-signer existence, token-account owners) can pass minContextSlot set to the key’s last landed slot from the audit log. Optional.
13. Running under systemd
A chapter beside Running in Docker with an example unit, as general practice: a dedicated user; UMask=0077; StateDirectory= and ConfigurationDirectory= at 0700; ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, NoNewPrivileges=yes; an empty CapabilityBoundingSet=; RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX; SystemCallFilter=@system-service; MemoryDenyWriteExecute=yes; LimitCORE=0 and MemorySwapMax=0 (with the backlog’s secrets-in-memory item); TimeoutStopSec=230 (Serving over HTTP and MCP); LoadCredentialEncrypted= for proposal 11’s passphrase; and systemd-analyze security to check it. Low.
Not proposed
- Signing without preflight, or simulating only on retries. Simulation before every send is one of the signer’s main checks and the source of the cap’s figures.
- Batching confirmations across requests. One sign polls one signature; batching would couple unrelated requests to save a few calls. Proposal 10 addresses RPC load directly.
- Holding the owner’s main key, encrypted with a secret stored beside it. A key whose unlock secret sits on the same disk is obscured, not protected. The signer’s answer is Path B.
- Hosting several operators’ keys in one signer. It serves one operator; isolation is by OS user or container.
- Rate limits scaled by game state (the number of fleets, say). The signer does not track game state; per-key limits set by the operator are enough.
- Pricing game actions (cargo or ships at risk) and combat rules (target lists, engagement caps). The signer does not decode game state, so it cannot price it; lamports are what it can measure, and an unmeasurable spend is already refused. Game-level limits belong in the agent or its planner.
- Pausing on a sudden drop in what a read returns. The signer does not act on sweeps of game state; the nearest case, repeated
unknownoutcomes, is proposal 3’s automatic trip. - Binding the policy into the key file’s encryption. age has no associated data, and proposal 7 records drift without a new file format.
RPC calls and latency
A sign’s own work (the key lock, reading and writing the audit log) takes about 20 ms. Everything else is RPC round trips, one after another. Against rpc1.z.ink each round trip took about 157 ms in the traces below, so the number of calls, and whether each one needs a new connection, decide how long a sign takes.
Connections
Rpc::new (src/rpc.rs) hands every caller in a process the same HTTP client for a given RPC URL, so they share one connection pool. In serve that means every request thread and every key; a CLI command is its own process, so it starts with an empty pool every time.
stateDiagram-v2
[*] --> Opening: a call finds no usable idle connection
Opening --> InUse: TCP and TLS handshake (about two extra round trips)
InUse --> Idle: the call returns
Idle --> InUse: the next call within 2 minutes, after checking the server has not closed it
Idle --> Closed: idle for 2 minutes (MAX_IDLE)
Idle --> Closed: the server closed it (found when it is next taken from the pool)
InUse --> Closed: transport error or the 15 s call timeout (RPC_TIMEOUT)
Idle --> Closed: more than 3 idle for one host, or 10 in all
Closed --> [*]
- Opening costs about two extra round trips: the first call on a new connection took 475 to 500 ms against 157 ms for the rest.
- Idle time is
MAX_IDLE, 2 minutes (the library,ureq, defaults to 15 s, shorter than an agent’s usual pause between signs). Before reusing an idle connection,ureqchecks whether the server has closed it and opens a new one if so, so a server that closes sooner costs a handshake, not an error. The server could still close it just as it is reused; the first call of a sign is always a read, neversendTransaction. - How many:
ureqkeeps at most 3 idle connections per host and 10 in all. Signs running at the same time inserveeach take a connection; beyond 3 at once, the extra ones are closed after use and the next burst opens them again. - The process ends, and its connections with it. Restarting
servestarts cold.
The genesis hash
The chain check compares the RPC’s genesis hash with the cluster’s. A process reads it once per RPC URL and reuses it for 5 minutes (GENESIS_TTL), so most signs make no getGenesisHash call, and a URL that starts answering for another chain is refused within 5 minutes. A CLI command reads it every time.
A sign’s calls
sequenceDiagram
autonumber
participant S as Signer
participant R as RPC
opt the genesis hash is not cached (first sign, every 5 min, every CLI command)
S->>R: getGenesisHash
end
opt the payload has partial signers / creates token accounts
S->>R: getMultipleAccounts (new accounts must not exist, and token-account owners)
end
S->>R: getLatestBlockhash
S->>R: getBalance (the key, before)
S->>R: simulateTransaction
S->>R: getFeeForMessage
opt the simulation says BlockhashNotFound (signing only)
S->>R: getLatestBlockhash, simulateTransaction (once, with a fresh blockhash)
end
opt a confirm key: after the person approves
S->>R: getBalance, getLatestBlockhash, simulateTransaction, getFeeForMessage (a fresh blockhash)
end
S->>R: getBlockHeight (finalized: is the blockhash still valid?)
opt the blockhash is already past its last valid height
S->>R: the same four calls with a fresh blockhash, then getBlockHeight again
end
opt the signature is already in the audit log
S->>R: the same four calls with a fresh blockhash
end
Note over S: write the sign-intent entry
S->>R: sendTransaction (skipPreflight, maxRetries 0)
loop wait 400 ms, then 800 ms, then 1 s, until an outcome is proven (at most 150 s)
S->>R: getSignatureStatuses
opt not landed yet
S->>R: getBlockHeight (finalized), and past the blockhash's last valid height: getSignatureStatuses with history
end
opt every third check
S->>R: sendTransaction (rebroadcast)
end
end
S->>R: getTransaction (the exact fee and balance change, up to 10 tries)
Note over S: write the final sign entry
check stops after getFeeForMessage: it neither sends nor polls. getTransaction stays even though it costs a round trip: the daily cap charges what a transaction actually cost, not what it was simulated to cost.
Measured
Two signs by one session key through serve, about 27 s apart, against rpc1.z.ink (traces from 2026-11-09):
| Build | First sign | Sign after the pause | confirm | Calls in that sign |
|---|---|---|---|---|
| Before the connection changes | 2.76 s | 2.79 s | 1.48 s | 9 |
| One pool per URL, genesis cached | 2.80 s | 2.70 s (a new connection: 15 s idle limit) | 1.48 s | 8 |
| Plus 2 min idle, first check at 400 ms | 2.19 s | 1.70 s | 0.88 s | 8 |
The 1.70 s is now five reads before sending (about 800 ms), the send, the 400 ms wait, the status check and getTransaction (about 880 ms), and about 20 ms of local work.
Tracing a sign
Run with SA_FORGE_SIGNER_LOG=debug to see every RPC call with its method and duration (Tracing), and SA_FORGE_SIGNER_LOG_FORMAT=json to filter it:
jq -c 'select(.fields.message == "close")
| {span: .span.name, method: .span.method, busy: .fields."time.busy"}' trace.jsonl
just bench times the audit log’s reads on synthetic logs; it does not touch the RPC.
If a trace shows a problem
| What the trace shows | Likely cause | What to change |
|---|---|---|
| The first call of a sign after a pause takes about 3 times the others | The server closes idle connections sooner than 2 minutes | A light keep-alive call (such as getHealth) while serve is idle, or accept the handshake |
| A transport error on the first call after a pause | The server closed the connection just as it was reused | Retry a read once on a new connection; never retry sendTransaction blindly (a rebroadcast of the same signed transaction is safe, which the poll loop already does) |
Handshakes during bursts of parallel signs in serve | More than 3 connections in use at once | Raise max_idle_connections_per_host |
| Every call takes 150 ms or more | Network distance to the RPC | A closer RPC (Security on trusting it) |
| The five reads before sending dominate | They run one after another | Batch them as JSON-RPC batch requests (blockhash, balance and block height; then simulation and fee), if the RPC accepts batches: about 3 fewer round trips |
| Several status checks before the transaction lands | A congested cluster or slow slots | Tune the poll schedule (FIRST_POLL, POLL) |
| A CLI loop is slow on every sign | Each CLI command starts cold: a handshake and getGenesisHash | Run the loop through serve |
These are listed under “RPC latency” in the Plan’s backlog; none is planned until a trace shows the need.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
0.6.0 - 2026-11-11
Added
- [breaking] speak MCP revision 2026-07-28 only.
- serve llms.txt, llms-full.txt and an agent skill as MCP resources, and install the skill from sa-forge-signer-mcp.
- add sa-forge-signer-mcp, MCP over stdio for a running service.
- [breaking] move the service into its own binary, sa-forge-signer-serve.
Fixed
- [breaking] require https for rpc_url, allowing http only for a loopback host.
- simulate only unsigned copies, so the RPC never holds a transaction before its intent.
- count only signs that were not refused toward a key’s rate limit.
- escape caller text on approval prompts and cap it in audit entries.
Other
- check the MCP handler’s messages against the Rust MCP SDK’s types.
- say outright that sa-forge-signer-mcp needs a running sa-forge-signer-serve.
- add a design decisions chapter, and show in the README how an agent connects.
- split the engine into its pipeline, its rebuild gates, the approval prompt and the send loop.
- split serve into its routes and its MCP handler, and give the URL and caller-text helpers their own modules.
- split the crate into a library and a thin sa-forge-signer binary.
0.5.0 - 2026-11-09
Added
- add opt-in tracing on stderr (SA_FORGE_SIGNER_LOG) with timed spans for each sign phase, RPC call and serve request, and a just bench recipe timing the audit log’s reads.
- report the halt and the policy digest’s approval in the MCP key_show tool.
- add a policy digest per key, recorded in sign audit entries, and optional require_policy_approval with key approve.
- add instruction_table in report mode, reporting unknown instructions, wrong account counts and profiles other than the key’s as findings.
- [breaking] bound ComputeBudget instructions and System transfers per key.
- run check and sign through one policy function, and preview every sign-only refusal in check.
- [breaking] add halt and resume, a stop on signing that only a person at a terminal can lift.
- add the grant page, a local web page where the profile authority’s own browser wallet grants and revokes a SAGE session key.
Fixed
- check a sent transaction’s status after 400 ms and back off to 1 s, and keep idle RPC connections for 2 minutes between signs.
- keep one RPC connection pool per URL for the process, and reuse the genesis hash for 5 minutes, so a sign no longer pays a TLS handshake and an extra call.
- drop serve’s plain access line while tracing is on, so a JSON log holds only JSON, and say how to turn tracing on after install.
- read a key’s recent audit entries once per sign, and count its usage in linear time instead of quadratic.
- read the audit log back from its end for each sign, so a sign’s cost no longer grows with the log’s age.
- refuse a payload whose signature is already in the audit log unless a fresh blockhash changes it.
0.4.1 - 2026-11-07
Added
- add an optional explorer_url to the config, defaulting to the cluster’s explorer, used for the explorer link in sign reports and shown by config show.
- make key import of the same keypair a no-op that prints its config block again, still refusing a different key.
- point config init and config show at key import as well as key new.
- add key import, which copies an existing Solana CLI keypair file into the key store and prints its config block.
- add config init, which writes the annotated example config, and config show, which prints the config as the signer resolves it.
Other
- replace the bare allow on unless_stale with an expect that gives its reason.
- move the Working on it chapters into docs/src/dev.
- add a Getting started chapter with the full config for each setup, the minimal serve table, and a table of every setting and its default.
- name the package in the cargo install command, which cargo requires once the repository holds a second binary package.
- split the README into book chapters, and slim the README to a front page.
- build, attest and attach prebuilt binaries to each release.
- list the crate’s files in Cargo.toml, so CI and docs-only changes do not trigger a release.
- group only minor and patch Dependabot updates, so each major bump gets its own PR.
- (deps) bump Swatinem/rust-cache
0.4.0 - 2026-11-07
Fixed
- (deps) update rustls-webpki 0.103.15 -> 0.103.16 and webpki-roots 1.0.9 -> 1.0.11 for RUSTSEC-2026-0382 and RUSTSEC-2026-0381.
Other
- add weekly Dependabot updates for actions and cargo with a 7-day cooldown.
- release with release-plz in git-only mode, and document Conventional Commits and what counts as breaking.
- check the book on pull requests and deploy it to GitHub Pages from main.
- build the docs as an mdBook with llms.txt for agents, and point agents at it from CLAUDE.md.
- rustls 0.23.45 -> 0.23.46 for RUSTSEC-2026-0346..0349, allowed by a dated exception in the crate-age check (owner’s exception to the 7-day minimum age).
- Refuse a sign whose blockhash is already past the finalized height, after one fresh re-sign: it can never land, so nothing is sent and a retry is safe.
- Bump the version to 0.4.0 for the security review’s breaking changes.
- Require the real System and token programs in ATA creates; check token authorization before the key lock.
- Pinchy’s re-review of 2f942bf: the two P2 follow-ups
- Close Pinchy’s re-review of 6d58901 (P1-1..3, P2)
- Hardening and documentation from the review (P2)
- Keep the profile authority behind a person at the terminal (review P1)
- Serialize signs per key; reserve spend before sending (review P1)
- Simulate the post-approval transaction before sending it (review P1)
- Prove expiry against the finalized tip; bound every sign (review P1)
- Refuse top-level account creation and token-program calls (review P0)
- Add sa-forge-signer 0.3.3: a thin signer for SAGE on Z.ink testnet with structural checks, simulation, confirmation, a file key store, a hash-chained audit log, and serve with HTTP and MCP endpoints.
Writing the docs
These are the rules agents follow in docs/, kept in docs/CLAUDE.md.
The mdBook that is the signer’s documentation, built for agents first: llms.txt (an index with each chapter’s description and size), llms-full.txt, and each chapter’s Markdown beside the HTML. Readers choose chapters from that index, so a chapter’s description decides whether it gets read.
Where to edit
- Chapters live in
docs/src/: using the signer at the top level, working on it indocs/src/dev/(contributing, code map, plan, changelog, and these rules fordocs/andapps/), apps indocs/src/apps/, and optional tools indocs/src/appendix/(git-review, GPUI Kit skills), listed under# Appendixat the end ofSUMMARY.md. A chapter that is only frontmatter plus an mdBook#includeof a repository file (security; contributing, plan, changelog, writing-docs and apps indev/; git-review inappendix/) is written in that file: edit the file, never the wrapper or anything intarget/. An include path is relative to the chapter, so it gains a../per folder level. Never write the include directive itself, with its double braces, in a file the book includes: mdBook runs it. README.mdat the repository root is a short front page linking to the published book, not a chapter.- Apps under
apps/are documented indocs/src/apps/, listed under# Extrasat the bottom ofSUMMARY.md: a chapter per app, plus a row indocs/src/apps/README.md.
Writing a chapter
- Start with frontmatter:
description: "..."in one line, naming what the chapter covers in the reader’s terms. It becomes the chapter’s line inllms.txt. - Add it to
SUMMARY.mdin the right section; an unlisted file is not in the book. - One physical line per paragraph, list item and table row; never hard-wrap prose. No em dashes; use a colon, a comma or a hyphen.
- Plain and exact: say what the signer does and refuses, with the field, flag, exit code or path. Say what is not covered rather than implying it is. State facts once and link to the chapter that owns them instead of repeating them.
- Links are relative paths to files in the repository (
../setup.md#isolation), never intotarget/and never absolute site URLs. Links inside an included repository file resolve from that file, soSECURITY.mdlinks to the published site instead. - Mermaid diagrams go in fenced
mermaidblocks; they render in the HTML and stay as source in the llms output. - A change to behaviour, a flag, a config key or a route updates its chapter in the same change. Security-relevant gaps go in
SECURITY.md(threat model or “Not covered”), and planned work inPLAN.md.
Checking
Run just docs check from the repository root (or just check inside docs/) after any doc change: it fails on a build warning, an empty chapter (a missing include) or a link that leads nowhere. just docs builds the book and just docs serve previews it; just docs setup installs mdBook, mdbook-mermaid and mdbook-llms once.
Inside docs/, just check is this book check, not the signer’s gate. Run the gate from the repository root.
Docs-only changes use docs: commits, which do not bump the signer’s version.
Building an app
These are the rules agents follow in apps/, kept in apps/CLAUDE.md.
Extras that sit beside the signer: tools a human runs to watch or work with it. Each app is its own crate, not part of sa-forge-signer.
Rules every app keeps
- Never weaken the signer’s isolation. An app runs as the human, never the agent. It does not read keys, sign, or hold anything that can sign. The grant page is no exception: the human’s own wallet signs, and the page never holds or sees a key. It reads the signer’s state and config read-only (or through
serve’s routes) and never writes, locks or truncates them: write access tostate_dircan rewrite the audit chain. Nothing an app adds may give the agent’s OS user a path to the keys, config or state, or a way to approve a signature. - Approval does not go through a terminal pane or anything the agent’s user can script. See “Typing into the approval terminal” in
SECURITY.md. - Stay out of the signer’s build. Each app has its own
[workspace]table andCargo.lock, so it is not a member of the root package, and its dependencies stay out ofjust check, theciworkflow, the release binaries and the Docker image. Do not add an app to any of them. The separateappsworkflow runs an app’s own gate. - Package names start with
sa-forge-signer-(for examplesa-forge-signer-console), so they are not confused with other SAGE forge projects.
Code
- Same standards as the signer (the Code style section of
CONTRIBUTING.md): Rust edition 2024, the root’s[lints]block (no panics, noascasts, checked arithmetic) copied into the app’sCargo.toml, a copy ofclippy.toml,cargo fmt, and tests for logic. - When an app mirrors signer logic (the console mirrors the audit
Entryandlimits), say so in the module doc, keep it in step withsrc/, and keep a test that parses output written by the real signer, asapps/console/tests/fixtures/does. - Dependencies must be at least 7 days old. A fresh lockfile usually picks up newer ones: pin each back with
cargo update -p <name>@<version> --precise <older>untilscripts/ci/crate-age.sh apps/<app>/Cargo.lockpasses. The one exception is the console’s UI framework: its gate setsALLOW_YOUNG_PREFIXESso thegpui-kit,gpui-base,gpui-componentandgpui-precrate families may be newer (the owner’s choice: they release weekly and pin each other exactly). Every other crate in its lockfile still has to be 7 days old.
The console’s UI framework
The console is built with GPUI Kit (the gpui-kit crate). Before changing its UI:
- Read GPUI Kit’s guides at the tag matching the
gpui-kitversion inapps/console/Cargo.lock: the Coding Guides (skills/gpui-kit) before any state, structure or API decision, and the Design Guides (skills/gpui-kit-design-guides) before any screen or layout change.just apps gpui-kit-skillsoptionally installs both as Claude skills at that tag (see the book’s GPUI Kit skills appendix). They are third-party instructions: read them before relying on them. - For a component or a GPUI mechanism, find its page in
https://gpui-kit.com/llms.txtand fetch only that page (each is Markdown, for examplehttps://gpui-kit.com/component/table.md).llms-full.txtis millions of characters with every page in two languages: search it, never read it whole. - The docs follow GPUI Kit’s latest release. Check exact signatures in the crate source of the pinned version (
~/.cargo/registry/src/*/gpui-component-<version>/and its siblings). - Keep the console’s palette and label font in
src/theme.rs, even where the Design Guides suggest otherwise; follow them for structure, density and interaction.
Recipes and gate
Recipes live in apps/justfile, the root justfile’s apps module: run them as just apps <recipe> from the root, or just <recipe> inside apps/. Paths are relative to source_directory(). Give each app a run recipe named after it and an <app>-check gate (fmt, clippy with warnings as errors, tests, cargo audit, dependency age), and run that gate before committing. Each recipe calls scripts/ci/app-check.sh <app>, which the apps workflow (.github/workflows/apps.yml) also runs for the grant page and the IDL table generator, on a change under apps/ and weekly; it is not a required check. The console is not in that workflow yet, so its gate runs only locally.
A new app
apps/<app>/withCargo.toml(packagesa-forge-signer-<app>,publish = false,[workspace], the lints),clippy.tomlandCargo.lock.- Recipes in
apps/justfile, and a shortREADME.mdfront page (what it is, install, run, a link to its chapter) likeapps/console/README.md; the chapter holds everything else. - A chapter at
docs/src/apps/<app>.md, a row indocs/src/apps/README.md, and a line under# Extrasindocs/src/SUMMARY.md; thenjust docs check(seedocs/CLAUDE.md).
Commits use feat: / fix: as usual. release-plz builds the signer’s version only from the files in the root Cargo.toml’s include, so app changes never bump sa-forge-signer. Installing from a tag needs the package name (cargo install --locked --git ... --tag vX.Y.Z sa-forge-signer-<app>).
Apps
Extras that sit beside the signer under apps/, each its own crate with its own workspace and Cargo.lock. They are not part of the signer: just check, the ci workflow, the release binaries, the Docker image and the signer’s version leave them out, and a change to one does not bump sa-forge-signer. A separate apps workflow runs the grant page’s and the IDL table generator’s own gates when something under apps/ changes, and weekly; the console’s gate runs only locally.
None of them holds keys or signs with the signer’s keys. The grant page has the human’s own browser wallet sign; it holds no keys itself. Each runs as the human, not the agent, and the isolation rule still applies: nothing here should give the agent’s OS user a way into the signer’s keys, config or state.
| App | For |
|---|---|
| Console | A read-only desktop view of a running signer: serve health, the audit log and per-key limits |
| Grant page | A local web page where the profile wallet grants and revokes a session key (Path B) |
| IDL table | Generates the signer’s instruction table from the programs’ IDLs, without copying them |
Install an app from a release tag by naming its package (cargo install --locked --git https://github.com/ttdonovan/sa-forge-signer --tag vX.Y.Z <package>), or from a checkout with cargo install --locked --path apps/<app>.
Console
sa-forge-signer-console is a desktop window, built with GPUI Kit, for watching a signer. It reads the signer’s audit log and config and polls serve’s /health; it never writes, locks, signs or approves anything. It is an early spike in apps/console/. It always uses its own dark theme, whatever the system appearance, with the Tomorrow font for headings and labels (bundled in apps/console/assets/fonts/ under the SIL Open Font License 1.1, OFL.txt beside it). Building it needs Rust 1.95 or later; on macOS, macOS 15 or later and the Xcode Command Line Tools (xcode-select --install).
just apps console # the signer's default config
just apps console --config ./config.toml --state-dir ./state --url http://127.0.0.1:8790
| Flag | Default |
|---|---|
--config | $SA_FORGE_SIGNER_CONFIG, else ~/.config/sa-forge-signer/config.toml, as the signer |
--state-dir | the config’s state_dir (holds audit.jsonl) |
--url | http:// plus the config’s [serve] listen (0.0.0.0 becomes 127.0.0.1); none without a [serve] table |
What it shows
- Status bar:
serveup or down, the audit log’s hash chain (intact, or the first line that breaks it), how many sends are unresolved, and how long ago the last entry was written. - Keys: each key’s class, approval and pubkey (with a copy button and an explorer link), its last activity, and two bars: signs in the last minute against its rate limit, and ZINK spent or reserved in 24 h against its daily cap, as the signer computes them (see How it works). A bar turns amber at 80% and red at the limit. Click a key to filter the log by it; the key column is hidden while one key is shown.
- Audit log: newest first, one row per signature: the
sign-intentthe signer writes before sending is merged into thesignentry with its outcome. An intent with no outcome stays as its own row, marked unresolved, as is anunknownorexpiredoutcome; reconcile them before retrying (see Retrying safely). Each row shows its balance change in ZINK, the intent the agent gave, then the builder’s summary with addresses shortened (GwDE…dFvg). Rows that arrive while the console is open are highlighted for a few seconds. The filter box matches intent, summary, key, signature and outcome. “raw entries” shows every audit entry as the hash chain stores it, with its command. - Detail: click a row for a side panel: what was signed (intent, summary, programs), the result (times in UTC, signature, slot, balance change and the reserved spend in ZINK), and the hashes that tie it into the chain. Signatures, programs and slots have a copy button and link to the block explorer in the config’s
explorer_url(by defaulthttps://explorer.z.ink; see Configuration).
Keys: with the log focused, ↑ and ↓ move through the rows; / jumps to the filter box, and Esc leaves it or closes the detail panel. Cmd-Q quits.
It checks audit.jsonl about twice a second and, when its size or modification time has changed, reads only the lines appended since its last read, continuing the hash chain from there; a last line still being written is skipped. If the log shrank or the last line it read has changed, it reads the whole log again. An edit above that line would not be seen this way, so once a minute, if the log changed, it reads and checks the whole log. The config supplies key classes, approval and limits; a key that appears only in the log is shown with the default limits. The config holds no secrets, only token hashes.
Access
The console runs as the human, who needs read access to the signer’s state_dir and config. Give read access only (group read or a read-only mount): write access to state_dir lets its holder rewrite the audit chain (see Security). The agent’s OS user must have neither.
Watching a signer in Docker
Run the console on the host against the container from Running in Docker: health comes over the published port, and the audit log from the state mount. The config’s state_dir is a path inside the container, so pass the host side of the mount:
just apps console --config "$PWD/config.toml" --state-dir "$PWD/state"
listen = "0.0.0.0:8790" becomes http://127.0.0.1:8790, which matches -p 127.0.0.1:8790:8790; pass --url for any other port. /health needs no token. The state must be a bind mount: a named volume has no host path to read.
On macOS with Docker Desktop, files the container writes to a bind mount belong to your macOS user on the host (uid 10001 only inside the container), so the console can read them without extra permissions. The same holds for the keys mount; see Security.
Working on it
The crate has its own workspace and lockfile, so its dependency tree (GPUI and the rest) stays out of the signer’s gate. just apps console-check runs its fmt, clippy, tests, cargo audit and the 7-day dependency age; CI does not run it.
Changing the UI: GPUI Kit’s guides and docs, and how to read them, are in Building an app; its Claude skills can be installed with just apps gpui-kit-skills (optional; see GPUI Kit skills).
src/log.rs mirrors the signer’s audit Entry (src/audit.rs) and usage (src/facts.rs). Change them together: a test parses lines written by the signer (tests/fixtures/signer-audit.jsonl), so a change to the signer’s Entry fails it rather than silently breaking the chain check.
Grant page
sa-forge-signer-grant is a web page, served on 127.0.0.1, for the human who holds the profile wallet (Path B). It does the grant and revoke steps of the key lifecycle: it builds a Player Profile AddKeys or RemoveKeys from what you enter, simulates it, and has your browser wallet sign it. It holds no keys and never sees one. It is an early spike in apps/grant/: a Leptos app compiled to WebAssembly, with one small JavaScript file for the wallet and RPC calls and no npm dependencies.
Open it in the browser where your wallet extension already runs. Wallet extensions do not run inside desktop app webviews, so this is a page, not a desktop app.
rustup target add wasm32-unknown-unknown
cargo install --locked trunk
just apps grant # http://127.0.0.1:8791
Trunk downloads the wasm-bindgen CLI matching apps/grant/Cargo.lock the first time it builds, unless that version is already installed.
Granting a session key
- Network: the RPC defaults to
https://rpc1.z.ink. Every step that uses it (connecting, reading the profile, reviewing, signing) first checks its genesis hash and refuses any network other than Z.ink testnet. Editing the RPC field clears the check and any transaction under review, and a transaction reviewed against one URL is not sent through another. - Wallet: wallets that support Wallet Standard and
solana:signTransactionappear as buttons. Connecting looks for profiles that hold the wallet among their first four keys, and loads the profile if there is exactly one. Otherwise paste its address. - Profile: the table shows every key: index, pubkey, scope, permission names and expiry.
- Grant: paste the session key’s pubkey (
sa-forge-signer key show <name>), choose permissions (presets or individual SAGE player actions), and an expiration: 7, 30, 60 or 90 days, or No expiration (expire_time-1, the key works until it is revoked). It defaults to 7 days, and the page shows the date it ends. Review re-reads the profile, builds the instruction and simulates it. - Sign: check the review, then Sign with wallet and send. The log links the transaction on the explorer, and once it confirms shows the new key’s index. Give that index to the agent, which passes it as forge-mcp’s
keyIndex; the signer config has no index field, and itsprofilesetting is informational.
Caution. The permission bit numbers and names the page offers are not from a primary source. They are transcribed from SAGE forge’s permission tables, which are not public, because the programs’ IDLs do not define them. Only
ADD_KEYS,AUTHand SAGE’sDOCK(bit 12) have been confirmed on-chain so far. A wrong bit grants a different action than its name says, so check a new grant against what the agent can and cannot do before relying on it.
The bits, as apps/grant/src/perms.rs defines them (b(bit, name, player), where player marks a bit the page offers to a session key):
#![allow(unused)]
fn main() {
pub const DRAIN_SOL_VAULT: u32 = 63;
pub const SAGE: &[Bit] = &[
b(0, "MANAGE_GAME", false),
b(1, "MANAGE_SYSTEM", false),
b(2, "MANAGE_STAR", false),
b(3, "MANAGE_PLANET", false),
b(4, "MANAGE_SHIP", false),
b(5, "MANAGE_CHARACTER", false),
b(6, "MANAGE_STAR_BASE", false),
b(7, "MANAGE_MINE_ITEM", false),
b(8, "MANAGE_RESOURCE", false),
b(9, "REMOVE_SHIP_ESCROW", true),
b(10, "MOVE_FLEET", true),
b(11, "UNDOCK", true),
b(12, "DOCK", true),
b(13, "RENT_FLEET", true),
b(14, "DO_CRAFTING", true),
b(15, "MANAGE_CARGO_POD", true),
b(16, "ADD_REMOVE_CARGO", true),
b(17, "DO_STAR_BASE_UPGRADES", true),
b(18, "MANAGE_FLEET", true),
b(19, "MANAGE_FLEET_CARGO", true),
b(20, "DO_MINING", true),
b(21, "RESPAWN", true),
b(22, "MANAGE_SURVEY_DATA_UNIT", true),
b(23, "SCAN_SURVEY_DATA_UNIT", true),
b(24, "DO_STAR_BASE_UPKEEP", true),
b(25, "MANAGE_PROGRESSION", false),
b(26, "MANAGE_CREW_CONFIG", false),
b(27, "MANAGE_CREW", true),
b(28, "WITHDRAW_CREW", true),
b(29, "MANAGE_REGION", false),
b(30, "MANAGE_CARGO", false),
b(31, "MANAGE_CLAIM_STAKE_CONFIG", false),
b(32, "MANAGE_COMBAT", false),
b(33, "MANAGE_CLAIM_STAKE", true),
b(34, "MANAGE_CRAFTING", false),
b(35, "ENGAGE_IN_COMBAT", true),
b(36, "REPAIR", true),
b(37, "MANAGE_SCANNING", false),
b(38, "SCAN", true),
b(39, "MANAGE_LOCAL_MARKET_ORDERS", true),
b(40, "DO_RESEARCH", true),
b(41, "DAILY_CHECK_IN", true),
b(42, "MANAGE_CURRENCY", true),
];
pub const PROFILE: &[Bit] = &[
b(0, "AUTH", false),
b(1, "ADD_KEYS", false),
b(2, "REMOVE_KEYS", false),
b(3, "CHANGE_NAME", false),
b(4, "CREATE_ROLE", false),
b(5, "REMOVE_ROLE", false),
b(6, "SET_AUTHORIZER", false),
b(7, "JOIN_ROLE", false),
b(8, "LEAVE_ROLE", false),
b(9, "TOGGLE_ACCEPTING_NEW_MEMBERS", false),
b(10, "ADD_MEMBER", false),
b(11, "REMOVE_MEMBER", false),
];
pub const FACTION: &[Bit] = &[b(0, "SET_FACTION", false)];
}
The presets, by SAGE bit:
#![allow(unused)]
fn main() {
/// SAGE presets for a session key. None includes `DRAIN_SOL_VAULT`; it is a separate choice.
pub const PRESETS: &[Preset] = &[
Preset {
name: "Fleet and movement",
bits: &[10, 11, 12, 18, 19],
},
Preset {
name: "Mining and cargo",
bits: &[16, 19, 20, 38],
},
Preset {
name: "Market trading",
bits: &[16, 19, 39],
},
Preset {
name: "All player actions",
bits: &[
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 27, 28, 33, 35, 36, 38,
39, 40, 41, 42,
],
},
];
}
Revoking
Revoke on a key’s row builds RemoveKeys for that one index, simulates it, and shows it for review in the same way. RemoveKeys names only an index, so the page also keeps the pubkey of the row you clicked: it refuses to review or sign if the profile, re-read at each step, now holds another key at that index, and after the transaction confirms it says whether that key is gone. Auth keys cannot be revoked here. Removing a key may move every key after it down one index: the table is re-read after it confirms, so re-check the indexes the agent passes to forge-mcp. Then sa-forge-signer key destroy the key file (Key lifecycle).
What it checks
- Grants are SAGE-scoped only, from the player-action bits; admin bits are not offered.
DRAIN_SOL_VAULT(bit 63) is a separate checkbox, off by default: account-creating actions such as creating fleets, crafting and claim stakes need it, and it also lets the key spend the profile’s SOL vault. - The connected wallet must be an unexpired Player Profile key on the profile with
ADD_KEYS(to grant) orREMOVE_KEYS(to revoke), orAUTH. A profile whose key threshold is above 1 is refused, since the page signs with one wallet. - A key already on the profile and unexpired is refused; revoke it first to change its permissions or expiry. The session key may not be the wallet itself.
- Every transaction is built from the form and a fresh read of the profile, never from pasted input. It is simulated before review and rebuilt with a fresh blockhash at signing.
- The signed transaction must carry exactly the message the page built. If the wallet changed it (some wallets add their own instructions), nothing is sent.
Limits
- Wallets on Z.ink: Phantom has no custom networks, so its preview and warnings for a Z.ink transaction may be wrong or missing. The page sends through its own RPC, not the wallet’s. Backpack and Solflare accept a custom RPC. Not yet tested with each wallet on Z.ink.
- Ledger: a Ledger signs through the wallet it is connected to. Its Solana app does not decode Player Profile instructions, so it needs blind signing, and the device shows only a hash: check the review on the page before you approve.
- Trust the page you run: whoever can change the files the page is built from can change what you sign, and with blind signing you would not see it. Build and serve it from a checkout the agent’s OS user cannot write, as with the signer’s own isolation.
- The Player Profile layouts are written by hand from the program’s IDL; tests check them against output from its SDK (
apps/grant/tests/fixtures/). Profile discovery searches only the first four key positions.
IDL table
sa-forge-signer-idl-table turns the programs’ Codama IDLs into the instruction table that hardening proposal 4 checks payloads against. It writes only facts about each instruction, plus each IDL’s sha256 and where the IDLs came from, so the IDLs are never checked in here. The IDLs of these programs are not published on Z.ink testnet (no Anchor IDL account, and no Program Metadata program); today they come from a sage-forge checkout.
just apps idl-table --cluster zink-testnet --from "sage-forge <commit> hearth/generate-sdks/idls" \
--out tables/zink-testnet.json <sage.json> <player-profile.json> <profile-faction.json>
It refuses a file that is not a Codama programNode (or a rootNode holding one), an instruction whose first argument is not its 8-byte discriminator, and a discriminator used twice in one program.
What the table holds
The output is JSON, sorted, so a regeneration diffs cleanly. format (1 today) changes when a field changes meaning. programs lists each program’s name, address, IDL version and the IDL file’s sha256. Each entry of instructions has:
| Field | Meaning |
|---|---|
program, discriminator, name | The program address, the 8-byte discriminator in hex, and the instruction’s name |
accounts | min and max account counts (they differ by the optional accounts) and the account names in IDL order |
signers | Account positions that sign (isSigner true or "either") |
profiles | Each profile check the program makes: a <prefix>Signer account that must be a key of the <prefix>Profile account, with key_index: the argument holding that key’s index and its byte offset in the instruction data |
funder | The position of the account named funder, which pays for what the instruction creates |
Player Profile’s key signer and profile account form a group with the empty prefix. A key-index argument is paired with its group by name (currentKeyIndex with current, gameAdminKeyIndex with gameAdminValidation, keyIndex with profile or profileValidation); a single group and argument left unmatched are paired with each other.
What it cannot tell
- Offsets behind variable-size arguments. A key index after a string, vector, option or data-carrying enum has no fixed
offset(null). - Key indexes the IDL does not name.
addKeystakeskeyAddIndexandkeyPermissionsIndex, and SAGE’saddShipEscrowhas akeypair beside itsprofileSignergroup that useskeyIndex; those groups have nokey_index(null). - Player Profile’s role instructions (
createRole,setRoleName, …) take a key index but nokeysigner, so they have noprofilesentry. - Families (movement, cargo, recovery, …) are not in the IDL at all.
- Positions when an optional account is left out. Positions follow the IDL’s order, which holds when every optional account is passed (Codama’s default puts the program id in an omitted slot).
After a program upgrade
Regenerate from the new IDLs with --from naming their source, and commit the table in the same change as anything that depends on it. The programs hashes say which IDLs a table came from.
just apps idl-table-check is its gate: fmt, clippy, tests, advisories and dependency age. Like every app it stays out of the signer’s build; the apps workflow runs this gate.
Appendix: optional tools
Suggestions, not requirements: the gate, the docs check and the rules in Working on it do not depend on any of them.
| Tool | Use it for |
|---|---|
| Local review with git-review | Reviewing an agent’s branch as one staged diff and approving it as one commit, without a pull request |
| GPUI Kit skills | Giving Claude GPUI Kit’s Coding and Design Guides while it changes the console’s UI |
git-review
A local merge-request workflow for just: squash the current branch into one staged diff against a target branch, review it, then approve it as a single commit. No GitHub/GitLab PR or MR needed. Built for Claude Code sessions, where the agent works on a claude/<topic> branch and the human reviews and approves.
Install
- Copy this folder into the repo (for example
scripts/git-review/). - Add to the root
justfile:import "scripts/git-review/justfile" - Make sure the root
justfilehas acheckrecipe (lint/tests). It gates bothmr-openandmr-approve.
Requires bash, git and just (tested with just 1.49). Nothing in the folder is repo-specific: scripts find the repo via git rev-parse --show-toplevel and the justfile finds the scripts via source_directory().
Commands
| recipe | what it does |
|---|---|
just mr-open [target] | Review the current branch against target (default main): runs just check, backs the tip up to mr-review/<branch>, records the target, soft-resets onto it. The whole branch becomes one staged diff. |
just mr-diff | Show the staged review diff (git diff --cached). |
just mr-diff-words | Same, word by word (for prose). |
just mr-log | Show the individual commits behind the open review. |
just mr-abort | Cancel: restore the individual commits. Edits made during the review stay uncommitted. |
just mr-approve "Message." | Runs just check, commits the staged diff as one commit (message must end with a full stop), fast-forwards the target, deletes the branch, backup and recorded target. Never pushes. |
mr-open refuses to start if the tree is dirty, there are untracked files, the target does not exist or has moved (rebase first), the branch is the target, or a review is already open.
State lives only in the local repo: the backup branch mr-review/<branch> and the git config key branch.<branch>.mrTarget. Both are removed by mr-approve or mr-abort.
With a Claude Code session
- Claude works on
claude/<topic>, committing in small steps. - When done, Claude runs
just mr-openand summarises the diff. - You review with
just mr-diff/just mr-log. Ask Claude for fixes; they get staged into the same diff. - You say “approve”; Claude runs
just mr-approve "Message.", or you run it yourself. Push separately when you choose.
Suggested CLAUDE.md block
## Git workflow (local review, no PR/MR)
- All Claude/agent work goes on a `claude/<topic>` branch created from the target (default `main`). Never commit on the target directly.
- Commit in small steps. When the work is done, run `just mr-open [target]` and summarise the staged diff for the owner.
- The owner reviews with `just mr-diff` / `just mr-diff-words` / `just mr-log`. Fixes requested during review are made in place and staged into the same diff.
- Run `just mr-approve "Message."` only when the owner explicitly approves. It commits one squashed commit and fast-forwards the target; it never pushes.
- Run `git push` only when the owner explicitly asks. `just mr-abort` restores the individual commits if the review is abandoned.
Optional: permission prompts
To make Claude Code always ask before approving or pushing, add to .claude/settings.json. Ask rules are checked before allow rules, so this prompts even if a broader rule such as Bash(just *) is allowed:
{
"permissions": {
"ask": ["Bash(just mr-approve *)", "Bash(git push *)"]
}
}
This matches the command as Claude writes it; it is a guard rail, not a security boundary (git -C . push would not match). The CLAUDE.md rule above is what keeps approvals and pushes owner-driven.
GPUI Kit skills
The console is built with GPUI Kit, whose authors publish two Claude skills: gpui-kit (the Coding Guides, component conventions and GPUI mechanics) and gpui-kit-design-guides (the Design Guides). Building an app says when to read them; installing them lets Claude Code load them itself.
just apps gpui-kit-skills # into ~/.claude/skills
just apps gpui-kit-skills --project # into this repository's .claude/skills (ignored by git)
just apps gpui-kit-skills --uninstall # remove what it installed
scripts/skills/gpui-kit.sh reads the gpui-kit version from apps/console/Cargo.lock and fetches only skills/ from https://github.com/longbridge/gpui-kit at the matching tag (--tag vX.Y.Z picks another), so the guides match the version the console builds against. Run it again after upgrading gpui-kit. It needs only git.
Each skill folder it installs gets a .installed-by-sa-forge-signer file naming the tag and commit. It replaces only folders carrying that file and refuses any other skill folder of the same name; --uninstall removes only marked folders.
The skills are third-party instructions: read each SKILL.md before installing. Installed in ~/.claude/skills, they load in every Claude Code session on the machine, not only in this repository; --project keeps them to this checkout. Claude Code picks them up in its next session.