@finchagentic/mcp 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wallet.js CHANGED
@@ -66,19 +66,54 @@ exports.MEV_PROTECT_ENABLED = !!process.env.FINCH_BROADCAST_RPC;
66
66
  exports.BASE_CHAIN_ID = 8453;
67
67
  const WALLET_DIR = path.join(os.homedir(), ".finch");
68
68
  const WALLET_FILE = path.join(WALLET_DIR, "wallet.json");
69
+ // Per-install random secret folded into the no-passphrase key derivation
70
+ // (see getMachineKey). Generated once via crypto.randomBytes and stored
71
+ // 0600 next to the wallet - real entropy, unlike hostname/platform/arch
72
+ // which are guessable/public and give an attacker who copies the wallet
73
+ // file everything they need to also derive the key.
74
+ const LOCAL_SECRET_FILE = path.join(WALLET_DIR, ".local-secret");
69
75
  let _cachedWallet = null;
70
76
  function clearWalletCache() { _cachedWallet = null; }
77
+ function getOrCreateLocalSecret() {
78
+ try {
79
+ const existing = fs.readFileSync(LOCAL_SECRET_FILE, "utf8").trim();
80
+ if (existing)
81
+ return existing;
82
+ }
83
+ catch { /* doesn't exist yet, or unreadable - (re)create below */ }
84
+ const secret = crypto.randomBytes(32).toString("hex");
85
+ if (!fs.existsSync(WALLET_DIR))
86
+ fs.mkdirSync(WALLET_DIR, { recursive: true });
87
+ fs.writeFileSync(LOCAL_SECRET_FILE, secret, { mode: 0o600 });
88
+ return secret;
89
+ }
71
90
  function getMachineKey() {
72
91
  // A passphrase is meant to make the wallet portable (move the encrypted
73
92
  // file + set the same passphrase elsewhere and it still decrypts) - so when
74
93
  // one is set, derive the key from ONLY the passphrase, no machine binding.
75
- // Without a passphrase, fall back to machine info as convenience-only
76
- // encryption (prevents casual reads, not security against an attacker who
77
- // has both the file and the system info).
78
94
  const passphrase = process.env.FINCH_WALLET_PASSPHRASE ?? "";
79
95
  if (passphrase) {
80
96
  return crypto.createHash("sha256").update(passphrase).digest("hex").slice(0, 32);
81
97
  }
98
+ // Without a passphrase, this is convenience-only encryption - it still
99
+ // can't stop an attacker who obtains BOTH files (the encrypted wallet and
100
+ // this local secret), the same as any locally-stored key material. What it
101
+ // does stop is the weaker, more common case this used to be vulnerable to:
102
+ // hostname/platform/arch alone are public/guessable, so a copy of just the
103
+ // wallet file (backup sync, stolen disk, malware scraping known paths) used
104
+ // to be enough to brute-force the key offline. Folding in a random,
105
+ // file-local secret means the wallet file alone is no longer sufficient.
106
+ return crypto
107
+ .createHash("sha256")
108
+ .update(getOrCreateLocalSecret() + os.hostname() + os.platform() + os.arch())
109
+ .digest("hex")
110
+ .slice(0, 32);
111
+ }
112
+ /** Pre-entropy-fix no-passphrase key: machine info only, no local secret.
113
+ * Kept solely so wallets encrypted before this fix still open; migrated to
114
+ * the new scheme in place on first successful decrypt, same pattern as
115
+ * getLegacyMachineKey below. */
116
+ function getLegacyMachineOnlyKey() {
82
117
  return crypto
83
118
  .createHash("sha256")
84
119
  .update(os.hostname() + os.platform() + os.arch())
@@ -91,9 +126,15 @@ function getMachineKey() {
91
126
  * decrypted on the exact machine that created it - never portable. Kept
92
127
  * solely so those existing wallets still open; getOrCreateWallet migrates
93
128
  * them to the portable scheme in place on first successful decrypt.
129
+ *
130
+ * `passphrase` is a parameter, not read from env, because the whole point of
131
+ * this fallback is testing what the file was ACTUALLY encrypted with - which
132
+ * may not be today's FINCH_WALLET_PASSPHRASE. The most common case this
133
+ * exists for: a user who never set a passphrase before (so the file was
134
+ * encrypted with "" + machine info) setting one for the FIRST time just now -
135
+ * at that moment env has the new passphrase, but the file predates it.
94
136
  */
95
- function getLegacyMachineKey() {
96
- const passphrase = process.env.FINCH_WALLET_PASSPHRASE ?? "";
137
+ function getLegacyMachineKey(passphrase) {
97
138
  return crypto
98
139
  .createHash("sha256")
99
140
  .update(passphrase + os.hostname() + os.platform() + os.arch())
@@ -106,13 +147,34 @@ function warnIfNoPassphrase() {
106
147
  return;
107
148
  _passphraseWarned = true;
108
149
  // stderr only - stdout is reserved for MCP JSON-RPC framing when running as a server.
109
- process.stderr.write("\n⚠️ FINCH_WALLET_PASSPHRASE is not set. Your Base mainnet wallet " +
110
- `(${WALLET_FILE}) is encrypted with a key derived only from this machine's ` +
111
- "hostname/platform/arch - low entropy, and crackable by anyone who copies the " +
112
- "file (backup sync, stolen disk, malware). Set FINCH_WALLET_PASSPHRASE to a " +
113
- "strong secret for real protection. This wallet holds real funds.\n\n");
150
+ process.stderr.write("\n⚠️ FINCH_WALLET_PASSPHRASE is not set. Your local wallet " +
151
+ `(${WALLET_FILE}, used on both Base and Robinhood Chain) is encrypted with a key derived from a random ` +
152
+ `per-install secret (${LOCAL_SECRET_FILE}) plus this machine's hostname/platform/arch. That stops the wallet ` +
153
+ "file alone from being crackable, but anyone who copies BOTH files together (backup sync, stolen disk, " +
154
+ "malware) still gets the wallet. Set FINCH_WALLET_PASSPHRASE to a strong secret you keep out of that backup " +
155
+ "for real protection. This wallet holds real funds.\n\n");
114
156
  }
157
+ let _walletCreationPromise = null;
115
158
  async function getOrCreateWallet() {
159
+ if (_cachedWallet)
160
+ return _cachedWallet;
161
+ // In-process mutex: two concurrent first-run callers (before _cachedWallet
162
+ // is set) must not each independently generate + write their own random
163
+ // wallet - only one write can ever survive on disk, and the loser would go
164
+ // on signing in-memory with a keypair that no longer matches what's
165
+ // persisted, silently switching the user's wallet identity mid-session.
166
+ // Chain all concurrent first-run callers through one shared promise.
167
+ if (_walletCreationPromise)
168
+ return _walletCreationPromise;
169
+ _walletCreationPromise = loadOrCreateWallet();
170
+ try {
171
+ return await _walletCreationPromise;
172
+ }
173
+ finally {
174
+ _walletCreationPromise = null;
175
+ }
176
+ }
177
+ async function loadOrCreateWallet() {
116
178
  if (_cachedWallet)
117
179
  return _cachedWallet;
118
180
  warnIfNoPassphrase();
@@ -129,22 +191,55 @@ async function getOrCreateWallet() {
129
191
  // machine (it still needs that machine's hostname/platform/arch) - it
130
192
  // can't rescue a wallet file copied to a new machine from before this
131
193
  // fix; there was no passphrase-only secret saved anywhere to recover.
132
- try {
133
- const wallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encrypted, getLegacyMachineKey());
134
- _cachedWallet = wallet;
194
+ //
195
+ // Try two legacy candidates: today's passphrase (in case it was already
196
+ // set when this file was encrypted) and "" (the common case - a user
197
+ // setting FINCH_WALLET_PASSPHRASE for the first time, whose existing
198
+ // file predates having any passphrase at all).
199
+ const legacyCandidates = [...new Set([process.env.FINCH_WALLET_PASSPHRASE ?? "", ""])];
200
+ let legacyWallet = null;
201
+ for (const candidate of legacyCandidates) {
202
+ try {
203
+ legacyWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encrypted, getLegacyMachineKey(candidate));
204
+ break;
205
+ }
206
+ catch { /* try next candidate */ }
207
+ }
208
+ if (legacyWallet) {
209
+ _cachedWallet = legacyWallet;
135
210
  // Migrate in place to the portable scheme now that we've proven we
136
211
  // hold the right key, so this only ever needs to happen once.
137
212
  try {
138
- const migrated = await wallet.encrypt(getMachineKey());
213
+ const migrated = await legacyWallet.encrypt(getMachineKey());
139
214
  fs.writeFileSync(WALLET_FILE, migrated, { mode: 0o600 });
140
215
  process.stderr.write(`\nMigrated ${WALLET_FILE} to the portable passphrase scheme.\n\n`);
141
216
  }
142
217
  catch {
143
218
  /* migration is best-effort - the legacy key still works next run either way */
144
219
  }
145
- return wallet;
220
+ return legacyWallet;
221
+ }
222
+ // Third tier: no passphrase ever set, and this wallet predates the fix
223
+ // that folds a random local secret into the no-passphrase key (it was
224
+ // encrypted with hostname/platform/arch alone). Try that exact old
225
+ // derivation before giving up.
226
+ if (!process.env.FINCH_WALLET_PASSPHRASE) {
227
+ try {
228
+ const oldNoPassWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encrypted, getLegacyMachineOnlyKey());
229
+ _cachedWallet = oldNoPassWallet;
230
+ try {
231
+ const migrated = await oldNoPassWallet.encrypt(getMachineKey());
232
+ fs.writeFileSync(WALLET_FILE, migrated, { mode: 0o600 });
233
+ process.stderr.write(`\nMigrated ${WALLET_FILE} to the higher-entropy no-passphrase scheme.\n\n`);
234
+ }
235
+ catch {
236
+ /* migration is best-effort - the legacy key still works next run either way */
237
+ }
238
+ return oldNoPassWallet;
239
+ }
240
+ catch { /* not this scheme either - fall through to the hard failure below */ }
146
241
  }
147
- catch {
242
+ {
148
243
  // A wallet file already exists but couldn't be decrypted under either
149
244
  // scheme - this almost always means FINCH_WALLET_PASSPHRASE doesn't
150
245
  // match what encrypted it, or this is a different machine and no
@@ -166,9 +261,25 @@ async function getOrCreateWallet() {
166
261
  if (!fs.existsSync(WALLET_DIR))
167
262
  fs.mkdirSync(WALLET_DIR, { recursive: true });
168
263
  const encrypted = await wallet.encrypt(getMachineKey());
169
- fs.writeFileSync(WALLET_FILE, encrypted, { mode: 0o600 });
170
- _cachedWallet = wallet;
171
- return wallet;
264
+ try {
265
+ // Exclusive create ("wx") - guards the cross-process version of the race
266
+ // the in-process mutex above already closes: two separate `finch`
267
+ // invocations racing on the very first run, before this file exists.
268
+ fs.writeFileSync(WALLET_FILE, encrypted, { mode: 0o600, flag: "wx" });
269
+ _cachedWallet = wallet;
270
+ return wallet;
271
+ }
272
+ catch (writeErr) {
273
+ if (writeErr?.code !== "EEXIST")
274
+ throw writeErr;
275
+ // Another process won the race and created the file first. Use theirs -
276
+ // never sign with the wallet we generated in memory once it's clear it
277
+ // isn't the one actually persisted to disk.
278
+ const encryptedExisting = fs.readFileSync(WALLET_FILE, "utf8");
279
+ const existingWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encryptedExisting, getMachineKey());
280
+ _cachedWallet = existingWallet;
281
+ return existingWallet;
282
+ }
172
283
  }
173
284
  async function signRequest(toolName) {
174
285
  const wallet = await getOrCreateWallet();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finchagentic/mcp",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "The runtime layer for Agentic AI. Persistent memory, autonomous agents, and workflows that survive every session.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "preferGlobal": true,
11
11
  "scripts": {
12
- "build": "tsc",
12
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
13
13
  "dev": "ts-node src/index.ts",
14
14
  "start": "node dist/index.js",
15
15
  "test": "vitest run",
@@ -1,150 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FRAMEWORK_TOOLS = void 0;
4
- exports.handleFrameworkTool = handleFrameworkTool;
5
- const convex_js_1 = require("../convex.js");
6
- exports.FRAMEWORK_TOOLS = [
7
- {
8
- name: "list_playbooks",
9
- description: "List available Noel Framework playbooks - predefined multi-step workflows. " +
10
- "Includes 4 system playbooks (Daily Market Scan, DCA Setup, Portfolio Rebalance Check, " +
11
- "Research Sweep) plus any you've created. Each step is Sentinel-gated.",
12
- inputSchema: { type: "object", properties: {}, required: [] },
13
- },
14
- {
15
- name: "run_playbook",
16
- description: "Execute a Noel Framework playbook. Each step runs through Sentinel before the " +
17
- "matching tool executes it. Steps map directly to finch tools (market, vault, agent, " +
18
- "memory, automation). Playbook halts immediately if Sentinel blocks a step.",
19
- inputSchema: {
20
- type: "object",
21
- properties: {
22
- playbook_name: {
23
- type: "string",
24
- description: "Exact name of the playbook. Use list_playbooks to see available ones.",
25
- },
26
- task_description: {
27
- type: "string",
28
- description: "Optional context passed as overrideParams to the playbook run.",
29
- },
30
- },
31
- required: ["playbook_name"],
32
- },
33
- },
34
- {
35
- name: "get_finch_ledger",
36
- description: "Get the Noel Framework audit trail - every Sentinel gate decision " +
37
- "(approved / blocked / warned), which checks ran, duration, and reason. " +
38
- "Full transparency on what agents are and aren't allowed to do.",
39
- inputSchema: { type: "object", properties: {}, required: [] },
40
- },
41
- ];
42
- async function handleFrameworkTool(name, args) {
43
- const a = (args ?? {});
44
- switch (name) {
45
- // ── list_playbooks ──────────────────────────────────────────────────────
46
- case "list_playbooks": {
47
- const result = await (0, convex_js_1.callConvex)("/framework/playbooks", "GET", undefined, "list_playbooks");
48
- const pbs = result.playbooks ?? [];
49
- if (pbs.length === 0) {
50
- return { content: [{ type: "text", text: "No playbooks found." }] };
51
- }
52
- const list = pbs
53
- .map((p) => {
54
- const steps = (() => {
55
- try {
56
- return JSON.parse(p.steps).length;
57
- }
58
- catch {
59
- return "?";
60
- }
61
- })();
62
- return `• **${p.name}**${p.isPublic ? " 🌐" : " 👤"} - ${steps} steps\n ${p.description}\n Used ${p.usageCount} times`;
63
- })
64
- .join("\n\n");
65
- return { content: [{ type: "text", text: `**Available Playbooks**\n\n${list}` }] };
66
- }
67
- // ── run_playbook ────────────────────────────────────────────────────────
68
- case "run_playbook": {
69
- if (!a.playbook_name) {
70
- return { content: [{ type: "text", text: "playbook_name is required" }], isError: true };
71
- }
72
- // Resolve playbook ID by name
73
- const pbList = await (0, convex_js_1.callConvex)("/framework/playbooks", "GET", undefined, "run_playbook");
74
- const playbook = (pbList.playbooks ?? []).find((p) => p.name.toLowerCase() === String(a.playbook_name).toLowerCase());
75
- if (!playbook) {
76
- return {
77
- content: [{
78
- type: "text",
79
- text: `Playbook "${a.playbook_name}" not found. Use list_playbooks to see available ones.`,
80
- }],
81
- isError: true,
82
- };
83
- }
84
- const result = await (0, convex_js_1.callConvex)("/framework/playbook/run", "POST", {
85
- playbookId: playbook._id,
86
- overrideParams: a.task_description,
87
- }, "run_playbook");
88
- if (result.error) {
89
- return { content: [{ type: "text", text: `Run failed: ${result.error}` }], isError: true };
90
- }
91
- if (result.blocked) {
92
- return {
93
- content: [{
94
- type: "text",
95
- text: [
96
- `🛡️ **Sentinel blocked playbook at step ${result.step}**`,
97
- ``,
98
- `**Tool:** ${result.tool}`,
99
- `**Reason:** ${result.reason}`,
100
- ``,
101
- `This is a mechanical safety gate. The action violates the agent's permission boundary.`,
102
- `Completed steps before block: ${result.results?.length ?? 0}`,
103
- ].join("\n"),
104
- }],
105
- };
106
- }
107
- const steps = result.results ?? [];
108
- const succeeded = steps.filter(r => r.success).length;
109
- const stepLines = steps.map((r) => `${r.success ? "✅" : "❌"} Step ${r.step} [${r.role}]: ${r.tool}${r.error ? ` - ${r.error}` : ""}`);
110
- return {
111
- content: [{
112
- type: "text",
113
- text: [
114
- `✅ **Playbook "${a.playbook_name}" completed**`,
115
- ``,
116
- `${succeeded}/${steps.length} steps successful`,
117
- `Run ID: \`${result.runId}\``,
118
- ``,
119
- ...stepLines,
120
- ].join("\n"),
121
- }],
122
- };
123
- }
124
- // ── get_finch_ledger ─────────────────────────────────────────────────────
125
- case "get_finch_ledger": {
126
- const result = await (0, convex_js_1.callConvex)("/swarm/ledger", "GET", undefined, "get_finch_ledger");
127
- const entries = result.entries ?? [];
128
- if (entries.length === 0) {
129
- return {
130
- content: [{
131
- type: "text",
132
- text: "No ledger entries yet. Run a playbook to see Sentinel decisions.",
133
- }],
134
- };
135
- }
136
- const lines = entries.map((e) => {
137
- const icon = e.decision === "approved" ? "✅" : e.decision === "blocked" ? "🚫" : "⚠️";
138
- return `${icon} **${e.agentId}** → \`${e.action}\`\n ${e.reason} (${e.durationMs}ms)`;
139
- });
140
- return {
141
- content: [{
142
- type: "text",
143
- text: `**Noel Ledger** (last ${entries.length} decisions)\n\n${lines.join("\n\n")}`,
144
- }],
145
- };
146
- }
147
- default:
148
- return null;
149
- }
150
- }