Skip to content

Commit 2193a26

Browse files
committed
fix: MCP security trust, built-in @blockrun/mcp, token anchor safety, session recovery (v2.1.0)
Security: project .mcp.json requires trust, write symlink target check MCP: @blockrun/mcp auto-registered, 5s connect timeout, 30s tool timeout, transport cleanup Tokens: anchor sanity check on large history growth, LLM parse warning Session: JSONL per-line recovery, prune protects active session Tools: truncation at line boundary, imagegen download timeout, compact threshold capped
1 parent a63ed43 commit 2193a26

File tree

25 files changed

+325
-85
lines changed

25 files changed

+325
-85
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

3+
## 2.1.0 (2026-04-04)
4+
5+
### Security
6+
- **MCP project config trust**: `.mcp.json` from project directories now requires explicit trust (`/mcp trust`). Prevents arbitrary code execution from untrusted repos.
7+
- **Write tool symlink protection**: Now also checks if the target file itself is a symlink to a sensitive location (was only checking parent directory).
8+
9+
### MCP Improvements
10+
- **@blockrun/mcp built-in**: BlockRun MCP server auto-registered — zero config needed for search, dex, markets, chat tools.
11+
- **5s connection timeout**: Slow MCP servers don't block startup anymore.
12+
- **30s tool call timeout**: Hanging MCP tools don't freeze the agent.
13+
- **Transport leak fix**: Failed connections now properly clean up stdio transport.
14+
15+
### Token Management
16+
- **Anchor sanity check**: Token anchor invalidated when history grows unexpectedly (e.g., /resume with large session). Falls back to estimation instead of wrong counts.
17+
- **LLM parse warning**: Malformed tool JSON input now logged in debug mode (was silently defaulting to {}).
18+
19+
### Bug Fixes
20+
- **Session JSONL recovery**: Corrupted lines now skipped individually instead of failing entire session load.
21+
- **Session prune safety**: Active session ID protected from pruning.
22+
- **Tool result truncation**: Now truncates at line boundaries for cleaner previews.
23+
- **ImageGen download timeout**: 30s timeout on image URL download (was unlimited).
24+
- **Compact threshold**: Keep boundary now 8-20 messages (was unbounded 30% that could prevent compaction on long sessions).
25+
326
## 2.0.0 (2026-04-04)
427

528
### MCP Support (Model Context Protocol)

dist/agent/compact.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,11 @@ async function compactHistory(history, model, client, debug) {
136136
* Keeps the most recent tool exchange + the last few user/assistant turns.
137137
*/
138138
function findKeepBoundary(history) {
139-
// Keep at least the last 6 messages (3 turns), or ~30% of history
140-
const minKeep = Math.min(6, history.length);
141-
const pctKeep = Math.ceil(history.length * 0.3);
142-
let keep = Math.max(minKeep, pctKeep);
139+
// Keep the last 8-20 messages (absolute range, not percentage)
140+
// Prevents "never compacts" bug when history grows large
141+
const minKeep = Math.min(8, history.length);
142+
const maxKeep = Math.min(20, history.length - 1);
143+
let keep = Math.max(minKeep, Math.min(maxKeep, Math.ceil(history.length * 0.3)));
143144
// Make sure we don't split in the middle of a tool exchange
144145
// (assistant with tool_use must be followed by user with tool_result)
145146
while (keep < history.length) {

dist/agent/llm.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ export class ModelClient {
129129
try {
130130
parsedInput = JSON.parse(currentToolInput || '{}');
131131
}
132-
catch { /* empty */ }
132+
catch (parseErr) {
133+
// Log malformed JSON instead of silently defaulting to {}
134+
if (this.debug) {
135+
console.error(`[runcode] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
136+
}
137+
}
133138
const toolInvocation = {
134139
type: 'tool_use',
135140
id: currentToolId,

dist/agent/loop.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
215215
// Session persistence
216216
const sessionId = createSessionId();
217217
let turnCount = 0;
218-
pruneOldSessions(); // Cleanup old sessions on start
218+
pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
219219
while (true) {
220220
let input = await getUserInput();
221221
if (input === null)

dist/agent/optimize.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ export function budgetToolResults(history) {
4949
// Per-tool cap
5050
if (size > MAX_TOOL_RESULT_CHARS) {
5151
modified = true;
52-
const preview = content.slice(0, PREVIEW_CHARS);
52+
// Truncate at line boundary for cleaner output
53+
let preview = content.slice(0, PREVIEW_CHARS);
54+
const lastNewline = preview.lastIndexOf('\n');
55+
if (lastNewline > PREVIEW_CHARS * 0.5) {
56+
preview = preview.slice(0, lastNewline);
57+
}
5358
budgeted.push({
5459
type: 'tool_result',
5560
tool_use_id: part.tool_use_id,

dist/agent/tokens.js

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,26 @@ export function updateActualTokens(inputTokens, outputTokens, messageCount) {
2323
* More accurate than pure estimation because it's grounded in actual API counts.
2424
*/
2525
export function getAnchoredTokenCount(history) {
26-
if (lastApiInputTokens > 0 && history.length >= lastApiMessageCount) {
27-
// Anchor to API count, estimate only new messages
28-
const newMessages = history.slice(lastApiMessageCount);
29-
let newTokens = 0;
30-
for (const msg of newMessages) {
31-
newTokens += estimateDialogueTokens(msg);
26+
if (lastApiInputTokens > 0 && lastApiMessageCount > 0 && history.length >= lastApiMessageCount) {
27+
// Sanity check: if history was mutated (compaction, micro-compact), anchor may be stale.
28+
// Detect by checking if new messages were only appended (length grew), not if content changed.
29+
// If history grew by more than expected (e.g., resume injected many messages), fall through to estimation.
30+
const growth = history.length - lastApiMessageCount;
31+
if (growth <= 20) { // Reasonable growth since last API call
32+
const newMessages = history.slice(lastApiMessageCount);
33+
let newTokens = 0;
34+
for (const msg of newMessages) {
35+
newTokens += estimateDialogueTokens(msg);
36+
}
37+
const total = lastApiInputTokens + newTokens;
38+
return {
39+
estimated: total,
40+
apiAnchored: true,
41+
contextUsagePct: 0,
42+
};
3243
}
33-
const total = lastApiInputTokens + newTokens;
34-
return {
35-
estimated: total,
36-
apiAnchored: true,
37-
contextUsagePct: 0, // Will be calculated by caller with model context window
38-
};
44+
// Too much growth — anchor is unreliable, fall through to estimation
45+
resetTokenAnchor();
3946
}
4047
// No anchor — pure estimation
4148
return {

dist/mcp/client.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface McpConfig {
2727
}
2828
/**
2929
* Connect to all configured MCP servers and return discovered tools.
30+
* Each connection has a 5s timeout to avoid blocking startup.
3031
*/
3132
export declare function connectMcpServers(config: McpConfig, debug?: boolean): Promise<CapabilityHandler[]>;
3233
/**

dist/mcp/client.js

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,17 @@ async function connectStdio(name, config) {
2121
env: { ...process.env, ...(config.env || {}) },
2222
});
2323
const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} });
24-
await client.connect(transport);
24+
try {
25+
await client.connect(transport);
26+
}
27+
catch (err) {
28+
// Clean up transport if connect fails to prevent resource leak
29+
try {
30+
await transport.close();
31+
}
32+
catch { /* ignore */ }
33+
throw err;
34+
}
2535
// Discover tools
2636
const { tools: mcpTools } = await client.listTools();
2737
const capabilities = [];
@@ -38,11 +48,12 @@ async function connectStdio(name, config) {
3848
},
3949
},
4050
execute: async (input, _ctx) => {
51+
const MCP_TOOL_TIMEOUT = 30_000;
4152
try {
42-
const result = await client.callTool({
43-
name: tool.name,
44-
arguments: input,
45-
});
53+
// Timeout protection: if tool hangs, don't block the agent forever
54+
const callPromise = client.callTool({ name: tool.name, arguments: input });
55+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool timeout after ${MCP_TOOL_TIMEOUT / 1000}s`)), MCP_TOOL_TIMEOUT));
56+
const result = await Promise.race([callPromise, timeoutPromise]);
4657
// Extract text content from MCP response
4758
const output = result.content
4859
?.filter(c => c.type === 'text')
@@ -70,6 +81,11 @@ async function connectStdio(name, config) {
7081
/**
7182
* Connect to all configured MCP servers and return discovered tools.
7283
*/
84+
const MCP_CONNECT_TIMEOUT = 5_000; // 5s per server connection
85+
/**
86+
* Connect to all configured MCP servers and return discovered tools.
87+
* Each connection has a 5s timeout to avoid blocking startup.
88+
*/
7389
export async function connectMcpServers(config, debug) {
7490
const allTools = [];
7591
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
@@ -79,17 +95,16 @@ export async function connectMcpServers(config, debug) {
7995
if (debug) {
8096
console.error(`[runcode] Connecting to MCP server: ${name}...`);
8197
}
82-
let connected;
83-
if (serverConfig.transport === 'stdio') {
84-
connected = await connectStdio(name, serverConfig);
85-
}
86-
else {
87-
// HTTP transport — TODO: implement SSE/HTTP transport
98+
if (serverConfig.transport !== 'stdio') {
8899
if (debug) {
89100
console.error(`[runcode] MCP HTTP transport not yet supported for ${name}`);
90101
}
91102
continue;
92103
}
104+
// Timeout: don't let a slow server block startup
105+
const connectPromise = connectStdio(name, serverConfig);
106+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('connection timeout (5s)')), MCP_CONNECT_TIMEOUT));
107+
const connected = await Promise.race([connectPromise, timeoutPromise]);
93108
allTools.push(...connected.tools);
94109
if (debug) {
95110
console.error(`[runcode] MCP ${name}: ${connected.tools.length} tools discovered`);

dist/mcp/config.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
* 2. Project: .mcp.json in working directory
66
*/
77
import type { McpConfig, McpServerConfig } from './client.js';
8-
/**
9-
* Load MCP server configurations from global + project files.
10-
* Project config overrides global for same server name.
11-
*/
128
export declare function loadMcpConfig(workDir: string): McpConfig;
139
/**
1410
* Save a server config to the global MCP config.
@@ -18,3 +14,7 @@ export declare function saveMcpServer(name: string, config: McpServerConfig): vo
1814
* Remove a server from the global MCP config.
1915
*/
2016
export declare function removeMcpServer(name: string): boolean;
17+
/**
18+
* Trust a project directory to load its .mcp.json.
19+
*/
20+
export declare function trustProjectDir(workDir: string): void;

dist/mcp/config.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,18 @@ const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json');
1212
* Load MCP server configurations from global + project files.
1313
* Project config overrides global for same server name.
1414
*/
15+
// Built-in MCP server: @blockrun/mcp is always available (zero config)
16+
const BUILTIN_MCP_SERVERS = {
17+
blockrun: {
18+
transport: 'stdio',
19+
command: 'npx',
20+
args: ['-y', '@blockrun/mcp'],
21+
label: 'BlockRun (built-in)',
22+
},
23+
};
1524
export function loadMcpConfig(workDir) {
16-
const servers = {};
25+
// Start with built-in servers
26+
const servers = { ...BUILTIN_MCP_SERVERS };
1727
// 1. Global config
1828
try {
1929
if (fs.existsSync(GLOBAL_MCP_FILE)) {
@@ -27,14 +37,28 @@ export function loadMcpConfig(workDir) {
2737
// Ignore corrupt global config
2838
}
2939
// 2. Project config (.mcp.json in working directory)
40+
// Security: project configs can execute arbitrary commands via stdio transport.
41+
// Only load if a trust marker exists (user has explicitly opted in).
3042
const projectMcpFile = path.join(workDir, '.mcp.json');
43+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
3144
try {
3245
if (fs.existsSync(projectMcpFile)) {
33-
const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
34-
if (raw.mcpServers && typeof raw.mcpServers === 'object') {
35-
// Project overrides global for same name
36-
Object.assign(servers, raw.mcpServers);
46+
// Check if this project directory is trusted
47+
let trusted = false;
48+
try {
49+
if (fs.existsSync(trustMarker)) {
50+
const trustedDirs = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
51+
trusted = Array.isArray(trustedDirs) && trustedDirs.includes(workDir);
52+
}
53+
}
54+
catch { /* not trusted */ }
55+
if (trusted) {
56+
const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
57+
if (raw.mcpServers && typeof raw.mcpServers === 'object') {
58+
Object.assign(servers, raw.mcpServers);
59+
}
3760
}
61+
// If not trusted, silently skip project config (user must run /mcp trust)
3862
}
3963
}
4064
catch {
@@ -62,6 +86,24 @@ export function removeMcpServer(name) {
6286
fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n');
6387
return true;
6488
}
89+
/**
90+
* Trust a project directory to load its .mcp.json.
91+
*/
92+
export function trustProjectDir(workDir) {
93+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
94+
let trusted = [];
95+
try {
96+
if (fs.existsSync(trustMarker)) {
97+
trusted = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
98+
}
99+
}
100+
catch { /* fresh */ }
101+
if (!trusted.includes(workDir)) {
102+
trusted.push(workDir);
103+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
104+
fs.writeFileSync(trustMarker, JSON.stringify(trusted, null, 2));
105+
}
106+
}
65107
function loadGlobalMcpConfig() {
66108
try {
67109
if (fs.existsSync(GLOBAL_MCP_FILE)) {

0 commit comments

Comments
 (0)