{
  "markdown": "# sigil\n\n> Claude can sign, but never see.\n\n`sigil` is a local signing tool and Claude Code integration that lets agentic coding tools use private keys without ever putting key material in the model's context window.\n\n**Status:** pre-alpha. The MCP server, CLI, unlock flow, ward hooks, policy engine (static checks), out-of-band confirmation via ntfy, Solana signing, and the JSON-RPC signing proxy (Foundry/Hardhat) all work end-to-end. Rolling-window value caps and EIP-712 domain allowlists are not yet implemented. Until they land — and until the supply-chain attestations promised for v0.1.0 ship — **do not use this with real funds yet.** Build plan lives in the [tracking issue](https://github.com/cdrn/sigil/issues/9).\n\n## What it is\n\nOne MCP server process, four bins (plus a legacy `sigild` alias), six runtime deps (all pinned, zero transitive):\n\n1. **`sigil-mcp`** — the only thing that runs. Claude Code spawns it per session via your `mcpServers` config; it dies when Claude exits. Holds unlocked keys in process memory (zeroized on shutdown, `sigil lock`, or unlock-failure; mlock against swap is planned). Keys at rest are encrypted with XChaCha20-Poly1305 and an Argon2id-derived key. Signs over stdio using a DIY MCP wire protocol (~200 lines, no SDK dep). Claude never sees key material — only opaque handles like `evm:executor`.\n2. **`sigil`** — control CLI. `init`, `status`, `portal new`/`add`/`list`/`qr`/`remove`, `policy show`/`init`, `unlock`, `lock`.\n3. **`sigil-hook-pre` / `sigil-hook-post`** — Claude Code hook binaries that block reads of common key paths and redact key-shaped strings from tool output.\n\n`sigil-mcp` boots **locked**: empty in-memory handle table, no keys loaded. Sign methods return `DAEMON_LOCKED` (-32003) with a \"run sigil unlock\" message until you push the passphrase in from a separate terminal via `sigil unlock`. That CLI connects to a per-session Unix socket at `~/.sigil/control/<pid>.sock` (0600) that `sigil-mcp` opens at startup — and fans out to every such socket so one `sigil unlock` reaches all open windows. After unlock, signs work for the rest of the session; `sigil lock` zeroizes the table without killing the process.\n\nSign methods exposed today: EIP-191 personal_sign, EIP-1559 + legacy transactions, EIP-712 typed data, plus Solana (ed25519) message + transaction signing — see [Solana support](#solana-svm) below.\n\n## What it isn't\n\n- Not a hardware wallet replacement. If you can use a Ledger or YubiKey, do that.\n- Not a custody solution. It runs on your laptop or VPS and protects you from one specific class of failure: leaking key material through an LLM agent.\n- A first cut of *bounding signing authority* via the policy engine — but not the full thing. Shipped: static checks (chain ID, destination allowlist, per-tx value cap, function-selector allowlist, contract-creation gating, on/off toggles for personal_sign and EIP-712) plus out-of-band human confirmation via ntfy. Rolling-window caps and EIP-712 domain allowlists are tracked in [#3](https://github.com/cdrn/sigil/issues/3) and will land incrementally.\n\n## Install\n\n```sh\nnpm install -g sigild\n```\n\nThis drops four binaries on your `$PATH`: `sigil`, `sigil-mcp`, `sigil-hook-pre`, `sigil-hook-post` (plus `sigild`, a legacy alias for `sigil-mcp`). (The package name on npm is `sigild` for legacy reasons; the bins do not include a daemon any more.)\n\nRequires Node 22+, macOS or Linux. (The CLI ↔ session control channel uses Unix domain sockets; Windows is untested and currently unsupported.)\n\n## Quick start\n\n```sh\n# 1. Wire sigil into Claude Code (project-scoped). Pass --user to do it globally.\nsigil init\n\n# 2a. Generate a fresh key inside sigil (no plaintext ever hits disk):\nsigil portal new evm:bot\n# → prompts for a passphrase, mints a fresh secp256k1 key, prints the\n#   address, writes ~/.sigil/keys/evm:bot.sigil + permissive policy.\n#\n# 2b. OR import an existing private key from a file:\n#     Accepts either 32 raw bytes or 64 hex chars (with optional 0x prefix).\nsigil portal add evm:bot --key-file ./private.hex\n# → same as above but seeded from the file. Source file is deleted by\n#   default (pass --no-remove-source to keep it).\n#\n# Either form: pass --strict to start with a locked-down policy template\n# you fill in before any sign succeeds.\n\n# 3. Open Claude Code. It spawns sigil-mcp automatically via your MCP config.\n#    sigil-mcp boots locked — the first sign attempt will return DAEMON_LOCKED.\n\n# 4. In a separate terminal, push the passphrase to every running sigil-mcp.\nsigil unlock\n# → prompts once, decrypts every keyfile in ~/.sigil/keys/ into each open window\n\n# 5. Use Claude Code. The sigil_* tools (EVM + Solana) will work for the rest of the session.\n\n# Optional: re-lock without restarting Claude.\nsigil lock\n```\n\nIf you close Claude Code, `sigil-mcp` exits and its memory is wiped. Open a new session and `sigil unlock` again — the encrypted keyfiles on disk persist.\n\n## CLI reference\n\n```text\nsigil init [--user]\n  Project scope: writes the ward hooks to <cwd>/.claude/settings.json\n  and the MCP server registration to <cwd>/.mcp.json.\n  --user: writes hooks to ~/.claude/settings.json and the MCP server\n  registration to ~/.claude.json. (Claude Code CLI reads MCP configs\n  from .mcp.json / ~/.claude.json — not from settings.json.)\n  Idempotent — preserves your unrelated settings, and on upgrade\n  migrates any stale mcpServers.sigil entry out of settings.json.\n\nsigil portal new <handle> [--strict]\n  Generate a fresh secp256k1 key inside sigil, encrypt with your\n  passphrase, write it to ~/.sigil/keys/<handle>.sigil (mode 0600).\n  No plaintext key ever lands on disk. Use this when you want a clean\n  hot wallet for a bot (vs importing an existing key from a file).\n  Also writes ~/.sigil/policy/<handle>.toml — permissive by default,\n  or --strict for a locked-down template.\n\nsigil portal add <handle> --key-file <path> [--no-remove-source] [--strict]\n  Import an existing private key. Encrypts it with your passphrase\n  and stores at ~/.sigil/keys/<handle>.sigil (mode 0600). Handle\n  format is <kind>:<name> where kind is \"evm\". The source key file\n  is deleted by default — pass --no-remove-source to keep it.\n  Also writes ~/.sigil/policy/<handle>.toml — permissive by default\n  (signs anything), or --strict for a locked-down template you fill\n  in before signs succeed.\n\nsigil policy show <handle>\n  Print the current policy file for a portal. Validates schema; exits\n  1 if the file is missing or malformed.\n\nsigil policy init <handle> [--strict]\n  Provision a policy file for an existing portal whose policy is\n  missing (e.g. a keyfile from an older sigil version, or one you\n  manually deleted). Refuses to overwrite — edit the file directly\n  or remove it first. Defaults to permissive; --strict writes the\n  locked-down template.\n\nsigil rpc init <handle> --upstream <url> [--port <n>]\n  Enable the JSON-RPC signing proxy for a portal: generates a strong\n  auth token and appends the [rpc] block to ~/.sigil/config.toml\n  (refuses if one exists; preserves the rest of the file). Prints the\n  ready-to-paste authenticated endpoint and forge invocation. Restart\n  Claude Code sessions to pick it up.\n\nsigil portal list\n  List the encrypted keyfiles on disk with their derived addresses\n  (EVM + Solana). Requires the passphrase.\n\nsigil portal qr <handle>\n  Render a portal's address as a terminal QR code (for funding it from\n  a phone wallet). Requires the passphrase.\n\nsigil portal remove <handle>\n  Delete a keyfile from disk.\n\nsigil unlock\n  Prompt for the passphrase and push it to every running sigil-mcp at\n  once (one per Claude window). After unlock, sign calls succeed for the\n  rest of each session. Idempotent — sessions already unlocked are left\n  as-is. Fails if no sigil-mcp is running (start a Claude Code session\n  first).\n\nsigil lock\n  Tell every running sigil-mcp to zeroize and clear its in-memory keys.\n  Re-unlock with sigil unlock — the sigil-mcp processes keep running.\n\nsigil status\n  Report which sigil-mcp sessions are running (one entry per window,\n  with PID, unlocked flag, and loaded portals) and how many keyfiles\n  exist on disk. Does not require the passphrase.\n```\n\nSet `SIGIL_HOME` to override `~/.sigil`. Set `SIGIL_CONTROL_DIR` to override the control-socket directory.\n\n## Multi-window behaviour\n\nEach Claude Code window spawns its own `sigil-mcp`, and each binds its own control socket at `~/.sigil/control/<pid>.sock`. They share the on-disk keyfiles + audit log but keep separate in-memory handle tables.\n\n`sigil unlock` / `lock` / `status` fan out across **every** socket in `~/.sigil/control/`, so a single `sigil unlock` loads keys into all currently-open windows — no more guessing which process the CLI reaches. Sockets left behind by hard-killed sessions are detected and cleaned up automatically on the next CLI call.\n\nEach window still holds its own decrypted keys only for its own lifetime: closing a window zeroizes that session's keys, and a window opened *after* you unlock starts locked (run `sigil unlock` again to include it). Keys never outlive the Claude sessions that use them — a deliberate property from [#23](https://github.com/cdrn/sigil/issues/23).\n\nOS-keychain integration (planned, v0.3) will make unlock zero-touch for users who set it up.\n\n## Policy engine\n\nOnce a portal is unlocked, signing authority over its key is real. To bound the blast radius of a successful prompt injection, every portal has a policy file at `~/.sigil/policy/<handle>.toml`. Two modes:\n\n**Permissive** (default for `sigil portal add`): no rules. Sign anything the agent asks. The key isolation guarantees still hold — your key never enters the agent's context — but the unlocked portal can be made to sign whatever an attacker can get the agent to ask for. Useful for: testnet bots, demo flows, anyone who only cares about the context-window protection.\n\n**Strict** (opt in with `--strict`): every sign request is checked. Generated template:\n\n```toml\nmode = \"strict\"\n\nchain_ids = [1]                           # allowed chain IDs\nallow_to = []                             # allowed destination addresses (lowercase 0x)\nmax_value_wei = \"0\"                       # per-tx cap, in wei, as decimal string\nallowed_selectors = []                    # 4-byte function selectors, e.g. \"0xa9059cbb\"\n\nallow_contract_creation = false           # deploys (to = null); when true, every\n                                          # deploy still requires a confirm tap\n\nallow_message_signing = false             # EIP-191 personal_sign (e.g. SIWE)\nallow_typed_data = false                  # EIP-712 (Permit, OpenSea — can be financial)\n\n# Optional: above this value, sigil pushes a notification to your phone and\n# waits for an approve/deny tap before signing. See \"Out-of-band confirm\"\n# below. Must be strictly less than max_value_wei.\nrequire_confirm_above_wei = \"10000000000000000\"   # 0.01 ETH\n```\n\nA failed rule throws `POLICY_DENIED` (-32001) back to the agent with the human-readable reason (\"tx denied — value X exceeds max_value_wei Y\"), and the deny is appended to the hash-chained audit log alongside allows. Denies are forensically the more interesting half — they're the prompt-injection canary.\n\nWhat's deferred to follow-up PRs (still in [#3](https://github.com/cdrn/sigil/issues/3)): rolling-window value caps (e.g. 1 ETH/day per portal), EIP-712 domain + primary-type allowlists, decoded-calldata arg checks.\n\n## Out-of-band confirm\n\nFor sign requests above `require_confirm_above_wei`, sigil pushes a notification to a channel you control (not the agent) and waits for an explicit human ack before signing. Today the only transport is [ntfy](https://ntfy.sh) — zero-setup, no accounts. SMS and Telegram transports are wired behind the same `ConfirmTransport` interface and will land in follow-ups.\n\nWire it up in `~/.sigil/config.toml`:\n\n```toml\n[confirm.ntfy]\ntopic  = \"your-unguessable-string-here\"     # the topic name IS the credential\n# server = \"https://ntfy.example.com\"       # optional, default https://ntfy.sh\n\n[confirm]\n# timeout_ms = 60000                        # default 60s; timeout = deny\n```\n\nInstall the ntfy app on your phone, subscribe to that topic, and you'll get a push with **Approve** / **Deny** buttons every time the threshold is crossed. The buttons hit a local `127.0.0.1` listener inside `sigil-mcp` with a one-time, request-bound token — a leaked or replayed token can't approve a different sign. Timeout, deny click, and push-provider outage all fail closed.\n\nIf any policy file sets `require_confirm_above_wei` but no transport is configured, `sigil-mcp` refuses to start with a clear error rather than silently degrading every confirm-gated sign to a deny.\n\n## JSON-RPC signing proxy (Foundry / Hardhat / any web3 tool)\n\nsigil can expose a local JSON-RPC endpoint that makes any portal a drop-in signer for tooling that expects an unlocked node account — the same pattern Clef and web3signer use. Contract bytecode goes from `forge` straight into sigil; it never transits the agent's context or an MCP tool parameter.\n\nEnable it with one command (generates the token, writes the config block, prints the forge invocation):\n\n```sh\nsigil rpc init evm:bot --upstream https://sepolia.example/v3/KEY\n```\n\n...or by hand in `~/.sigil/config.toml`:\n\n```toml\n[rpc]\nportal   = \"evm:bot\"                        # which portal signs\nupstream = \"https://sepolia.example/v3/KEY\" # real node for everything else\ntoken    = \"<openssl rand -hex 24>\"         # required — guards the endpoint\n# port   = 8547                             # default 8547 (clear of anvil's 8545)\n```\n\nThen point any tool at it, with the token as the Basic-auth password:\n\n```bash\nforge script script/Deploy.s.sol \\\n  --rpc-url \"http://sigil:<token>@127.0.0.1:8547\" \\\n  --unlocked --sender 0xYourPortalAddress --broadcast\n```\n\nThe proxy serves three things with the portal key and forwards everything else (`eth_call`, `eth_estimateGas`, `eth_getTransactionReceipt`, …) to the upstream:\n\n- `eth_accounts` → the portal address (empty while locked)\n- `eth_signTransaction` → fills nonce/gas/fees if missing, signs, returns the raw tx\n- `eth_sendTransaction` → same, then broadcasts via the upstream and returns the hash\n\n**Security properties.** The listener binds `127.0.0.1` only; every request must present the config token (constant-time compared) and a loopback `Host` header (DNS-rebinding defence). Signing runs through the *identical* daemon pipeline as the MCP tools — policy checks, the out-of-band confirm gate, and the hash-chained audit log all apply, so this surface adds a transport, not a privilege. The filled transaction carries the **upstream's** chain id, so a strict policy's `chain_ids` allowlist binds the proxy to the network you configured. Message/typed-data methods (`eth_sign`, `personal_sign`, `eth_signTypedData*`) are rejected on this surface — use the MCP tools, which have their own policy toggles. A strict policy with `allow_contract_creation = true` gives you confirm-gated `forge script` deploys: forge submits, your phone buzzes, the tx signs when you tap approve.\n\nWith multiple Claude windows open, each `sigil-mcp` tries to bind the port; the first wins and the rest log and continue — any one session's proxy serves the machine.\n\nWhen the proxy is enabled, sigil-mcp advertises the endpoint — including the authenticated URL — in the `sigil_eth_sign_transaction` tool description, so the agent discovers it on its own and reaches for `forge --unlocked` instead of transcribing bytecode through the MCP tool. This is deliberate: the token gates other local software, not your agent (see THREAT_MODEL.md).\n\n## Solana (SVM)\n\nEvery portal also controls a **Solana address**, derived from the *same secret*. EVM uses secp256k1; Solana uses ed25519 — different curves, so you can't share a public key. But the portal's raw 32-byte secret doubles as an ed25519 seed, yielding one secret → two addresses:\n\n```\n$ sigil portal list\nevm:executor\n  evm: 0x1234…abcd\n  svm: 7vWxK…Qm9f          # base58 ed25519 address, same key\n```\n\nThis is exactly the derivation Phantom/Solflare perform on **\"import private key\"**, so the Solana address is recoverable there (it does *not* match a seed-phrase / BIP44 account — it's the raw-key account).\n\nTwo MCP tools:\n\n- **`sigil_svm_sign_message`** — sign arbitrary off-chain bytes (e.g. Sign-In With Solana) with the ed25519 key. Input is base64; returns a base58 signature.\n- **`sigil_svm_sign_transaction`** — pass a serialized Solana transaction *message* (base64, legacy or v0); sigil ed25519-signs those bytes and returns the base58 signature for you to assemble into the transaction.\n\n**Policy.** Solana hides most semantics behind account indices and on-chain state, so sigil only decodes what it can offline: **native SOL (System Program) transfers**, which it gates on `svm_allow_to` (base58 recipient allowlist) and `svm_max_lamports` (per-tx cap), exactly like EVM. Anything it *can't* fully decode — SPL tokens, program calls, address-lookup-table accounts — is **routed to the out-of-band confirm gate**, never silently allowed. Auto-allow is all-or-nothing: a tx is only signed without a human tap if **every** instruction decoded and passed policy. `require_confirm_above_lamports` adds a value threshold (and, in strict mode, the undecodable-tx confirm); in strict mode an undecodable tx with no confirm transport configured fails closed (deny).\n\nRelevant policy fields (`~/.sigil/policy/<handle>.toml`): `allow_svm_message_signing`, `svm_allow_to`, `svm_max_lamports`, `require_confirm_above_lamports`.\n\n## Supply chain posture\n\nKey-management libraries die from supply chain compromise, not from clever attacks on the code. Given the npm ecosystem in 2026 (Mini Shai-Hulud, Axios, pgserve, TanStack), `sigil` commits to:\n\n- **Zero install scripts.** No `postinstall`, `preinstall`, `prepare`. CI-enforced: every PR runs a guard that fails if any package in the resolved tree declares one.\n- **Six runtime deps, all version-pinned** (no caret ranges), all zero-transitive — the entire `npm ls --omit dev` tree is exactly these six packages:\n  - [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) for XChaCha20-Poly1305\n  - [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) for Argon2id, keccak256, sha2/sha512, HMAC\n  - [`@noble/secp256k1`](https://github.com/paulmillr/noble-secp256k1) for ECDSA (EVM)\n  - [`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519) for EdDSA (Solana)\n  - [`@iarna/toml`](https://github.com/iarna/iarna-toml) for parsing per-portal policy TOML files\n  - [`qrcode-generator`](https://github.com/kazuhikoarase/qrcode-generator) for `sigil portal qr` rendering\n- **No MCP SDK.** The official `@modelcontextprotocol/sdk` pulls 92 transitive deps (ajv, hono, cors, cross-spawn, etc) — unacceptable surface. We implement the MCP wire protocol directly in ~200 lines.\n- **No Bun.** Plain Node only. Bun is currently being weaponized by Mini Shai-Hulud as an evasion layer; we will not give that pattern any cover.\n- **Provenance attestations on every npm publish.** Starting v0.0.4, releases are built by [a GitHub Actions workflow](./.github/workflows/release.yml) under OIDC trusted-publisher auth, signed with a Sigstore attestation. No long-lived npm token; tampered or out-of-band publishes fail signature verification.\n- **CycloneDX SBOM attached to every GitHub Release.** Full transitive dep tree enumerated at release time.\n- **Install-scripts CI guard.** Every PR fails if any package in the resolved tree declares `preinstall` / `install` / `postinstall`. `.npmrc` already has `ignore-scripts=true` so these never actually run for us; the guard catches new transitive deps that might run for a user without our `.npmrc`.\n- **Still planned for v0.1.0:**\n  - Signed standalone binaries from GitHub Releases for users who'd rather not touch npm\n  - Action SHA pinning rotation via Dependabot\n\n## Verifying a release\n\nYou can confirm a `sigild` tarball was built by the public workflow at the commit it claims to come from:\n\n```sh\n# Validates every package in your install tree:\nnpm audit signatures\n\n# Inspect the attestation for a specific sigild version:\nnpm view sigild@<version> dist.attestations\n# → shows the workflow filename, the commit SHA, and the Sigstore signing cert\n```\n\nWhat the attestation tells you: this tarball was built by `cdrn/sigil`'s `.github/workflows/release.yml`, at a specific commit on `main`, at a specific time. It does *not* tell you that commit is non-malicious — for that, read the diff between the version you trust and the version you're upgrading to. But it does mean an attacker who steals an npm token can't publish a malicious `sigild` under our name; they'd need to compromise the GitHub repo + push a tag, which leaves an audit trail.\n\nEvery release also publishes a [CycloneDX SBOM](https://cyclonedx.org/) as a GitHub Release asset, enumerating every package (direct + transitive) in the install tree at the version pinned by `package-lock.json`:\n\n```sh\n# Download + inspect the SBOM for a specific release:\ngh release download v0.0.4 --repo cdrn/sigil --pattern '*.cdx.json'\n# → produces sigild-v0.0.4.cdx.json — feed to syft/grype/etc. for vuln scan\n```\n\n## Threat model\n\nSee [THREAT_MODEL.md](./THREAT_MODEL.md). Read it before trusting this with anything.\n\n## Development\n\n```sh\ngit clone https://github.com/cdrn/sigil\ncd sigil\nnpm install      # respects .npmrc ignore-scripts=true\nnpm test         # builds + runs 600+ tests; should finish in under 15s\n```\n\nSee [CONTRIBUTING.md](./CONTRIBUTING.md) for the PR-per-layer workflow.\n\n## License\n\nApache License 2.0. See [LICENSE](./LICENSE).\n",
  "bytes": 21737,
  "sha": "697542513cf7051947e3bb47321f37b5654d85582303d955765c55ee411af524",
  "repo_slug": "cdrn/sigil",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cdrn_sigil_05ee8242/readme"
}