Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Security

Threat model

ThreatControl
The agent reads the key file and skips every checkKeys 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 URLThe 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 logsTracing 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 agentconfirm 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 keyOn-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 signsa-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 serveEvery 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-signserve 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 timeoutNothing 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 destinationComputeBudget 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 capThe 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 signsA 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 limitsSigns 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 creationAssociated 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 startconfig 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 wallet key therefore cannot be auto or reachable through serve unless it sets allow_unattended = true; do that only for a throwaway testnet key.
  • Typing into the approval terminal. This applies to resume and key approve as much as to confirm. confirm cannot 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 type yes at the prompt. Answer confirm prompts 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 docker group; 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. summary and intent are 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 verify finds 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:

  1. Stop the service and any CLI signs.
  2. Copy state_dir/audit.jsonl somewhere safe.
  3. Run sa-forge-signer audit verify. The error names the first unreadable line.
  4. 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. Run audit verify again; it should pass. If any line other than the last fails, the log was edited: stop and investigate, do not repair.
  5. Reconcile before signing again. A cut-off sign-intent means nothing was sent: the intent is written before the send, and the send never happens if that write fails. A cut-off final sign entry 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’s signature and 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.