# 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](https://ttdonovan.github.io/sa-forge-signer/how-it-works.html), 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](https://ttdonovan.github.io/sa-forge-signer/check-and-sign.html#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 `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](https://ttdonovan.github.io/sa-forge-signer/how-it-works.html)), 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.
