diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8253182..d99c68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=12288 + - name: Signer smoke test + run: npm run test:smoke:signer + env: + NODE_OPTIONS: --max-old-space-size=12288 + typecheck: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/package.json b/package.json index f8fcd80..0a1643f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "npm run test:unit", "test:unit": "node --import tsx --test packages/*/test/*.test.ts", "test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts", + "test:smoke:signer": "npm run build && node --import tsx --test packages/signer/test/smoke/*.test.ts", "test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts" }, "devDependencies": { diff --git a/packages/signer/src/config.ts b/packages/signer/src/config.ts new file mode 100644 index 0000000..b0974dd --- /dev/null +++ b/packages/signer/src/config.ts @@ -0,0 +1,63 @@ +// Signer configuration, entirely from the environment. No network settings +// exist here by design — the signer has no endpoints to configure. +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { MnemonicKeySource, PrivateKeyKeySource, type KeySource } from './keysource.js'; +import type { SpendLimits } from './policy.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface SignerConfig { + keySource: KeySource; + limits: SpendLimits; + auditLogPath: string; +} + +function repoRootDefault(): string { + // From packages/signer/build/index.js, three levels up is the repo root. + return resolve(__dirname, '../../..'); +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): SignerConfig { + const keySource = resolveKeySource(env); + + return { + keySource, + limits: { + perTxLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_PER_TX ?? '100000000'), + dailyLovelace: BigInt(env.VECTOR_SIGNER_SPEND_LIMIT_DAILY ?? '500000000'), + }, + auditLogPath: env.VECTOR_SIGNER_AUDIT_LOG_PATH + ? resolve(env.VECTOR_SIGNER_AUDIT_LOG_PATH) + : resolve(repoRootDefault(), 'vector-signer-audit-log.json'), + }; +} + +function resolveKeySource(env: NodeJS.ProcessEnv): KeySource { + const accountIndex = Number(env.VECTOR_SIGNER_ACCOUNT_INDEX ?? '0'); + + if (env.VECTOR_SIGNER_PRIVATE_KEY && env.VECTOR_SIGNER_ADDRESS) { + return new PrivateKeyKeySource(env.VECTOR_SIGNER_PRIVATE_KEY, env.VECTOR_SIGNER_ADDRESS); + } + if (env.VECTOR_SIGNER_MNEMONIC) { + return new MnemonicKeySource(env.VECTOR_SIGNER_MNEMONIC, accountIndex); + } + if (env.VECTOR_SIGNER_MNEMONIC_FILE) { + let contents: string; + try { + contents = readFileSync(env.VECTOR_SIGNER_MNEMONIC_FILE, 'utf8'); + } catch (err) { + // Reference the source, never the contents. + throw new Error( + `Could not read VECTOR_SIGNER_MNEMONIC_FILE (${env.VECTOR_SIGNER_MNEMONIC_FILE}): ${err instanceof Error ? err.message : String(err)}` + ); + } + return new MnemonicKeySource(contents, accountIndex); + } + + throw new Error( + 'No key material configured. Set one of: VECTOR_SIGNER_MNEMONIC_FILE (recommended), ' + + 'VECTOR_SIGNER_MNEMONIC, or VECTOR_SIGNER_PRIVATE_KEY together with VECTOR_SIGNER_ADDRESS.' + ); +} diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts index 862da72..1840a75 100644 --- a/packages/signer/src/index.ts +++ b/packages/signer/src/index.ts @@ -1,3 +1,19 @@ // Local signing companion for the Vector MCP server. -// Speaks stdio only: no HTTP listener, no outbound network. See test/boundary.test.ts. -console.error('vector-mcp-signer: not yet implemented'); +// +// Speaks stdio only: no HTTP listener, no outbound network, no Provider. +// The signing key never leaves this process. See test/boundary.test.ts. +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { loadConfig } from './config.js'; +import { registerSignerTools } from './tools.js'; + +const config = loadConfig(); + +const server = new McpServer({ name: 'vector-mcp-signer', version: '0.1.0' }); +registerSignerTools(server, config); + +// stdout is the MCP channel — every diagnostic must go to stderr. +console.error(`vector-mcp-signer ready. Key source: ${config.keySource.describe()}`); +console.error(`Audit log: ${config.auditLogPath}`); + +await server.connect(new StdioServerTransport()); diff --git a/packages/signer/src/tools.ts b/packages/signer/src/tools.ts new file mode 100644 index 0000000..b9a5a84 --- /dev/null +++ b/packages/signer/src/tools.ts @@ -0,0 +1,139 @@ +import { z } from 'zod/v4'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx'; +import { decodeTransaction } from './decode.js'; +import { evaluate } from './policy.js'; +import { signTransaction } from './sign.js'; +import { AuditLog } from './audit.js'; +import type { SignerConfig } from './config.js'; + +const txCborShape = { txCbor: z.string().describe('Hex-encoded CBOR of an unsigned transaction') }; + +function text(body: string) { + return { content: [{ type: 'text' as const, text: body }] }; +} + +export function registerSignerTools(server: McpServer, config: SignerConfig): void { + const identity = config.keySource.load(); + const audit = new AuditLog(config.auditLogPath); + const ownAddresses = [identity.address]; + + server.tool( + 'vector_signer_get_address', + 'Get this signer\'s wallet address. Pass it to the builder as changeAddress when building a transaction.', + {}, + async () => text(`Signer address: ${identity.address}\nKey source: ${config.keySource.describe()}`) + ); + + server.tool( + 'vector_signer_decode_transaction', + 'Decode an unsigned transaction and report what it would move, without signing it. Use this to show a human what is about to be signed.', + txCborShape, + async ({ txCbor }) => { + try { + const tx = decodeTransaction(txCbor); + const decision = evaluate(tx, ownAddresses, config.limits, audit.committedTodayLovelace()); + const rows = tx.outputs + .map((o, i) => { + const mine = o.address === identity.address; + const assets = o.assets.length ? ` + ${o.assets.length} asset(s)` : ''; + return ` ${i}. ${mine ? '[change] ' : '[outgoing]'} ${lovelaceToAda(o.lovelace)} AP3X${assets}\n ${o.address}`; + }) + .join('\n'); + return text( + // "(read-only)", not "(not signed)": the latter still contains the + // substring "signed", which collides with this smoke test's own + // `!/signed/i.test(text)` check (verified empirically — see the + // task report). "would be ALLOWED" below is the same fix applied + // to the policy line, which otherwise would have read "would be + // signed" whenever a decoded transaction happened to be in-policy. + `# Transaction preview (read-only)\n\n` + + `Hash: ${tx.txHashHex}\nInputs: ${tx.inputCount}\nFee: ${lovelaceToAda(tx.fee)} AP3X\n` + + `Minted assets: ${tx.mintedAssetCount}\n\nOutputs:\n${rows || ' (none)'}\n\n` + + `**Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X** (outgoing outputs + fee; change excluded)\n` + + `Assets leaving: ${decision.assetMovements.length}\n\n` + + `Policy: ${decision.allowed ? 'would be ALLOWED' : `would be REFUSED - ${decision.reason}`}` + ); + } catch (err) { + return text(`Could not decode the transaction: ${err instanceof Error ? err.message : String(err)}`); + } + } + ); + + server.tool( + 'vector_signer_sign', + 'Decode, policy-check, and sign an unsigned transaction. Returns signed CBOR to submit via the builder. Refuses if the transaction violates spend policy.', + txCborShape, + async ({ txCbor }) => { + let tx; + try { + tx = decodeTransaction(txCbor); + } catch (err) { + // Fail closed: never sign bytes that were not fully understood. + return text(`REFUSED. ${err instanceof Error ? err.message : String(err)}`); + } + + const decision = evaluate(tx, ownAddresses, config.limits, audit.committedTodayLovelace()); + + if (!decision.allowed) { + audit.append({ + timestamp: new Date().toISOString(), + txHash: tx.txHashHex, + decision: 'refused', + netOutflowLovelace: decision.netOutflowLovelace.toString(), + reason: decision.reason, + assetMovements: decision.assetMovements.length, + }); + return text(`REFUSED. ${decision.reason}`); + } + + let signed; + try { + signed = signTransaction(txCbor, identity.privateKeyBech32); + } catch (err) { + return text(`REFUSED. Signing failed: ${err instanceof Error ? err.message : String(err)}`); + } + + audit.append({ + timestamp: new Date().toISOString(), + txHash: signed.txHashHex, + decision: 'signed', + netOutflowLovelace: decision.netOutflowLovelace.toString(), + assetMovements: decision.assetMovements.length, + }); + + return text( + `# Signed\n\nHash: ${signed.txHashHex}\n` + + `Net outflow: ${lovelaceToAda(decision.netOutflowLovelace)} AP3X\n\n` + + `Signed CBOR:\n\`\`\`\n${signed.signedCborHex}\n\`\`\`\n\n` + + `Submit it with the builder's vector_submit_transaction. Nothing has been submitted yet.` + ); + } + ); + + server.tool( + 'vector_signer_get_spend_limits', + 'Report this signer\'s spend limits, how much of today\'s budget is committed, and recent signing decisions.', + {}, + async () => { + const committed = audit.committedTodayLovelace(); + const remaining = config.limits.dailyLovelace - committed; + const recent = audit + .recent(5) + .map((e) => ` ${e.timestamp} ${e.decision.toUpperCase()} ${lovelaceToAda(BigInt(e.netOutflowLovelace))} AP3X ${e.txHash.slice(0, 16)}...${e.reason ? ` (${e.reason})` : ''}`) + .join('\n'); + return text( + `# Signer spend limits\n\n` + + `Per transaction: ${lovelaceToAda(config.limits.perTxLovelace)} AP3X\n` + + `Daily: ${lovelaceToAda(config.limits.dailyLovelace)} AP3X\n` + + `Committed today: ${lovelaceToAda(committed)} AP3X\n` + + `Remaining today: ${lovelaceToAda(remaining > 0n ? remaining : 0n)} AP3X\n\n` + + `Audit log: ${config.auditLogPath}\n\n` + + (recent ? `Recent decisions:\n${recent}` : 'No decisions recorded yet.') + + `\n\nNote: the daily total counts signed transactions at signing time. The signer has no ` + + `network access and cannot observe whether a submit later succeeded, so a failed submit ` + + `still counts against the budget.` + ); + } + ); +} diff --git a/packages/signer/test/smoke/signer-tools.test.ts b/packages/signer/test/smoke/signer-tools.test.ts new file mode 100644 index 0000000..42aabc7 --- /dev/null +++ b/packages/signer/test/smoke/signer-tools.test.ts @@ -0,0 +1,114 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const TEST_MNEMONIC = + 'test walk nut penalty hip pave soap entry language right filter choice'; + +const FIXTURE_CBOR = + '84a300d9010281825820e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3200018282581d71ab1aad52c4774e5da9f2c0fa1a4d07220a0bdd57ee3dce9be860dac61a002dc6c0825839013fd872a48d71872cf76528bb41dd2a5775e2c532c21662a47c6cebf44b6be5f33456f28523ed04d1aaad158541176425378d8ed8fce53f881a3b6a7221021a0002911fa0f5f6'; + +const EXPECTED_TOOLS = [ + 'vector_signer_decode_transaction', + 'vector_signer_get_address', + 'vector_signer_get_spend_limits', + 'vector_signer_sign', +]; + +let client: Client; +let dir: string; + +async function callText(name: string, args: Record = {}): Promise { + const r = await client.callTool({ name, arguments: args }); + const c = r.content as Array<{ type: string; text?: string }>; + return c?.[0]?.text ?? ''; +} + +before(async () => { + dir = mkdtempSync(join(tmpdir(), 'signer-smoke-')); + const mnemonicFile = join(dir, 'mnemonic.txt'); + writeFileSync(mnemonicFile, TEST_MNEMONIC, { mode: 0o600 }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['packages/signer/build/index.js'], + env: { + ...process.env, + VECTOR_SIGNER_MNEMONIC_FILE: mnemonicFile, + VECTOR_SIGNER_AUDIT_LOG_PATH: join(dir, 'audit.json'), + VECTOR_SIGNER_SPEND_LIMIT_PER_TX: '100000000', + VECTOR_SIGNER_SPEND_LIMIT_DAILY: '500000000', + }, + }); + client = new Client({ name: 'signer-smoke', version: '1.0.0' }); + await client.connect(transport); +}); + +after(async () => { + try { await client.close(); } catch {} + rmSync(dir, { recursive: true, force: true }); +}); + +describe('signer MCP server over stdio', () => { + test('exposes exactly the four expected tools', async () => { + const { tools } = await client.listTools(); + assert.deepStrictEqual(tools.map((t) => t.name).sort(), EXPECTED_TOOLS); + }); + + test('no tool accepts a mnemonic or private key parameter', async () => { + const { tools } = await client.listTools(); + for (const t of tools) { + const props = Object.keys((t.inputSchema as any).properties ?? {}); + for (const p of props) { + assert.ok(!/mnemonic|private_?key|seed/i.test(p), `${t.name} exposes key material as parameter "${p}"`); + } + } + }); + + test('get_address returns the derived addr1 address', async () => { + assert.match(await callText('vector_signer_get_address'), /addr1[0-9a-z]+/); + }); + + test('decode_transaction reports the net outflow without signing', async () => { + const text = await callText('vector_signer_decode_transaction', { txCbor: FIXTURE_CBOR }); + assert.match(text, /Net outflow/i); + assert.ok(!/signed/i.test(text), 'decode must not sign'); + }); + + // The fixture was built for a DIFFERENT wallet, so for this test key both of + // its outputs are foreign: net outflow is ~1000 AP3X, far over the 100 AP3X + // per-transaction limit. Refusal is the correct outcome and the security- + // relevant path, so assert it precisely rather than settling for "responded". + test('sign refuses a transaction whose net outflow exceeds the per-transaction limit', async () => { + const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR }); + assert.match(text, /REFUSED/); + assert.match(text, /per-transaction limit/i); + }); + + test('a refusal returns no signed CBOR', async () => { + const text = await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR }); + assert.ok(!/Signed CBOR/i.test(text), 'a refused transaction must not return signed bytes'); + }); + + test('a refusal is recorded in the audit log but consumes no daily budget', async () => { + await callText('vector_signer_sign', { txCbor: FIXTURE_CBOR }); + const limits = await callText('vector_signer_get_spend_limits'); + assert.match(limits, /REFUSED/, 'the refusal should appear in recent decisions'); + assert.match(limits, /Committed today:\s*0\.000000 AP3X/, 'a refusal must not consume budget'); + }); + + test('get_spend_limits reports the configured limits', async () => { + const text = await callText('vector_signer_get_spend_limits'); + assert.match(text, /100\.000000/); + assert.match(text, /500\.000000/); + }); + + test('sign rejects malformed CBOR', async () => { + const text = await callText('vector_signer_sign', { txCbor: 'not-hex' }); + assert.match(text, /could not be decoded|refus/i); + }); +}); diff --git a/packages/signer/tsup.config.ts b/packages/signer/tsup.config.ts index 7ced406..5e7f5dd 100644 --- a/packages/signer/tsup.config.ts +++ b/packages/signer/tsup.config.ts @@ -3,7 +3,16 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], - target: "es2020", + // es2022, not es2020 (Task 1's original value): index.ts uses a top-level + // `await server.connect(...)` (brief's own given code, Task 7), and + // top-level await is an ES2022 language feature — esbuild hard-errors + // ("Top-level await is not available in the configured target environment") + // rather than attempting an unsafe downlevel transform, unlike most other + // modern syntax. Node 22 (this package's engines.node floor) natively + // supports it. Scoped to the signer package only; the builder's own + // tsup.config.ts is untouched because vector.ts/index.ts never uses + // top-level await and has no need for the bump. + target: "es2022", outDir: "build", clean: true, splitting: false,