Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions agents.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,21 @@
"args": [
"@modelcontextprotocol/server-filesystem",
"/path/to/allowed/directory"
]
],
"alwaysAllow": ["list_directory", "file_info"]
},
{
"name": "github",
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "your-github-token"
}
},
"alwaysAllow": ["list_repositories"]
}
]
],
"nanocoderTools": {
"alwaysAllow": ["execute_bash"]
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using execute_bash in the alwaysAllow configuration is a high-risk security practice. While this is just an example file, consider:

  1. Using a less risky tool as the example (like write_file or string_replace)
  2. Adding a comment warning about the security implications of auto-allowing bash execution
  3. Documenting that users should carefully consider which tools to auto-approve

This would better demonstrate secure configuration practices and prevent users from blindly copying a potentially dangerous setting.

Suggested change
"alwaysAllow": ["execute_bash"]
"_comment": "Example configuration: auto-approving powerful tools (especially shell execution like `execute_bash`) can be dangerous. Carefully review which tools, if any, you add to alwaysAllow in your real configuration.",
"alwaysAllow": ["write_file"]

Copilot uses AI. Check for mistakes.
}
Comment on lines +59 to +62
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new top-level alwaysAllow field lacks documentation. This configuration is used specifically for non-interactive mode (--run flag) tool execution and serves a different purpose than nanocoderTools.alwaysAllow. Consider:

  1. Adding a comment in the example configuration explaining what this field does
  2. Creating or updating documentation to explain when to use top-level alwaysAllow vs nanocoderTools.alwaysAllow
  3. Clarifying that this is specifically for non-interactive mode tool approval

Without clear documentation, users may be confused about:

  • Why there are two similar alwaysAllow configurations
  • When to use each one
  • How they interact with each other

Copilot uses AI. Check for mistakes.
}
}
4 changes: 3 additions & 1 deletion docs/mcp-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Nanocoder supports three transport types for MCP servers:
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"],
"env": {}
"env": {},
"alwaysAllow": ["list_directory", "file_info"]
}
```

Expand Down Expand Up @@ -112,6 +113,7 @@ Nanocoder supports three transport types for MCP servers:

- `name` (required): Display name for the server
- `transport` (required): Transport type (`stdio`, `http`, `websocket`)
- `alwaysAllow` (optional): Array of MCP tool names that can run without user confirmation

### stdio Transport Fields

Expand Down
8 changes: 7 additions & 1 deletion source/commands/mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function MCP({toolManager}: MCPProps) {
"transport": "stdio",
"command": "node",
"args": ["path/to/server.js"],
"alwaysAllow": ["safe_read", "status"],
"env": {
"API_KEY": "your-key"
}
Expand Down Expand Up @@ -128,7 +129,12 @@ export function MCP({toolManager}: MCPProps) {
Tags: {serverInfo.tags.map(tag => `#${tag}`).join(' ')}
</Text>
)}

{!!serverInfo?.autoApprovedCommands?.length && (
<Text color={colors.secondary}>
Auto-approved tools:{' '}
{serverInfo.autoApprovedCommands.join(', ')}
</Text>
)}
{serverTools.length > 0 && (
<Text color={colors.secondary}>
Tools:{' '}
Expand Down
2 changes: 2 additions & 0 deletions source/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ function loadAppConfig(): AppConfig {
return {
providers: processedData.nanocoder.providers ?? [],
mcpServers: processedData.nanocoder.mcpServers ?? [],
alwaysAllow: processedData.nanocoder.alwaysAllow ?? [],
nanocoderTools: processedData.nanocoder.nanocoderTools ?? {},
};
}
} catch (error) {
Expand Down
16 changes: 16 additions & 0 deletions source/config/nanocoder-tools-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {appConfig} from '@/config/index';

/**
* Check if a nanocoder tool is configured to always be allowed
* @param toolName - The name of the tool to check
* @returns true if the tool is in the alwaysAllow list, false otherwise
*/
export function isNanocoderToolAlwaysAllowed(toolName: string): boolean {
const alwaysAllowList = appConfig.nanocoderTools?.alwaysAllow;

if (!Array.isArray(alwaysAllowList)) {
return false;
}

return alwaysAllowList.includes(toolName);
}
38 changes: 18 additions & 20 deletions source/hooks/chat-handler/conversation/conversation-loop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type {ConversationStateManager} from '@/app/utils/conversation-state';
import AssistantMessage from '@/components/assistant-message';
import {ErrorMessage} from '@/components/message-box';
import UserMessage from '@/components/user-message';
import {appConfig} from '@/config/index';
import {parseToolCalls} from '@/tool-calling/index';
import type {ToolManager} from '@/tools/tool-manager';
import type {LLMClient, Message, ToolCall, ToolResult} from '@/types/core';
import {getLogger} from '@/utils/logging';
import {MessageBuilder} from '@/utils/message-builder';
import {parseToolArguments} from '@/utils/tool-args-parser';
import {displayToolResult} from '@/utils/tool-result-display';
Expand Down Expand Up @@ -273,6 +273,9 @@ export const processAssistantResponse = async (
const toolsNeedingConfirmation: ToolCall[] = [];
const toolsToExecuteDirectly: ToolCall[] = [];

// Tools that are permitted to auto-run in non-interactive mode
const nonInteractiveAllowList = new Set(appConfig.alwaysAllow ?? []);

for (const toolCall of validToolCalls) {
// Check if tool has a validator
let validationFailed = false;
Expand All @@ -290,12 +293,8 @@ export const processAssistantResponse = async (
if (!validationResult.valid) {
validationFailed = true;
}
} catch (error) {
const logger = getLogger();
logger.debug('Tool validation threw error', {
toolName: toolCall.function.name,
error,
});
} catch {
// Validation threw an error - treat as validation failure
validationFailed = true;
}
}
Expand Down Expand Up @@ -329,15 +328,8 @@ export const processAssistantResponse = async (
args: unknown,
) => boolean | Promise<boolean>
)(parsedArgs);
} catch (error) {
const logger = getLogger();
logger.debug(
'needsApproval evaluation failed, requiring approval',
{
toolName: toolCall.function.name,
error,
},
);
} catch {
// If evaluation fails, require approval for safety
toolNeedsApproval = true;
}
}
Expand All @@ -347,13 +339,19 @@ export const processAssistantResponse = async (
// Execute directly if:
// 1. Validation failed (need to send error back to model)
// 2. Tool has needsApproval: false
// 3. In auto-accept mode (except bash which always needs approval)
// 3. Explicitly allowed in non-interactive mode
// 4. In auto-accept mode (except bash which always needs approval)
const isBashTool = toolCall.function.name === 'execute_bash';
if (
const isNonInteractiveAllowed =
nonInteractiveMode &&
nonInteractiveAllowList.has(toolCall.function.name);
const shouldExecuteDirectly =
validationFailed ||
!toolNeedsApproval ||
(developmentMode === 'auto-accept' && !isBashTool)
) {
isNonInteractiveAllowed ||
(developmentMode === 'auto-accept' && !isBashTool);

if (shouldExecuteDirectly) {
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code calculates shouldExecuteDirectly but never uses it to populate toolsToExecuteDirectly or toolsNeedingConfirmation arrays. This is a critical bug - tools will never be categorized for execution. You need to add logic after line 397 to push the toolCall into the appropriate array based on the shouldExecuteDirectly value, for example:

if (shouldExecuteDirectly) {
    toolsToExecuteDirectly.push(toolCall);
} else {
    toolsNeedingConfirmation.push(toolCall);
}
Suggested change
if (shouldExecuteDirectly) {
if (shouldExecuteDirectly) {
toolsToExecuteDirectly.push(toolCall);
} else {
toolsNeedingConfirmation.push(toolCall);

Copilot uses AI. Check for mistakes.
toolsToExecuteDirectly.push(toolCall);
} else {
toolsNeedingConfirmation.push(toolCall);
Expand Down
82 changes: 74 additions & 8 deletions source/mcp/mcp-client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import test from 'ava';
import {setCurrentMode} from '../context/mode-context';
import {MCPClient} from './mcp-client';

// ============================================================================
Expand Down Expand Up @@ -139,7 +140,6 @@ test('MCPClient: isServerConnected returns false for non-existent servers', t =>
t.false(client.isServerConnected('another-server'));
t.false(client.isServerConnected(''));
});

// ============================================================================
// Tests for getAllTools
// ============================================================================
Expand Down Expand Up @@ -874,13 +874,15 @@ test('MCPClient.getToolMapping: returns mapping from connected HTTP server', asy

// Verify mapping structure
const firstMapping = mapping.entries().next().value;
const [toolName, mappingInfo] = firstMapping;

t.is(typeof toolName, 'string');
t.deepEqual(mappingInfo, {
serverName: 'test-deepwiki',
originalName: toolName,
});
if (firstMapping) {
const [toolName, mappingInfo] = firstMapping;

t.is(typeof toolName, 'string');
t.deepEqual(mappingInfo, {
serverName: 'test-deepwiki',
originalName: toolName,
});
}

await client.disconnect();
});
Expand Down Expand Up @@ -943,3 +945,67 @@ test('MCPClient.connectToServer: validates websocket URL protocol', async t => {
{message: /websocket URL must use ws:\/\/ or wss:\/\/ protocol/i},
);
});

test('MCPClient: alwaysAllow disables approval prompts', async t => {
const client = new MCPClient();
const serverName = 'auto-server';

(client as any).serverTools.set(serverName, [
{
name: 'safe_tool',
description: 'Safe MCP tool',
inputSchema: {type: 'object'},
serverName,
},
]);

(client as any).serverConfigs.set(serverName, {
name: serverName,
transport: 'stdio',
alwaysAllow: ['safe_tool'],
});

setCurrentMode('normal');

const registry = client.getNativeToolsRegistry();
const tool = registry['safe_tool'];

t.truthy(tool);
const needsApproval =
typeof tool?.needsApproval === 'function'
? await tool.needsApproval({}, {toolCallId: 'test', messages: []})
: tool?.needsApproval ?? true;
t.false(needsApproval);
});

test('MCPClient: non-whitelisted tools still require approval', async t => {
const client = new MCPClient();
const serverName = 'restricted-server';

(client as any).serverTools.set(serverName, [
{
name: 'restricted_tool',
description: 'Requires approval',
inputSchema: {type: 'object'},
serverName,
},
]);

(client as any).serverConfigs.set(serverName, {
name: serverName,
transport: 'stdio',
alwaysAllow: [],
});

setCurrentMode('normal');

const registry = client.getNativeToolsRegistry();
const tool = registry['restricted_tool'];

t.truthy(tool);
const needsApproval =
typeof tool?.needsApproval === 'function'
? await tool.needsApproval({}, {toolCallId: 'test', messages: []})
: tool?.needsApproval ?? false;
t.true(needsApproval);
});
18 changes: 17 additions & 1 deletion source/mcp/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export class MCPClient {
private isConnected: boolean = false;
private logger = getLogger();

private isToolAutoApproved(toolName: string, serverName: string): boolean {
const serverConfig = this.serverConfigs.get(serverName);
if (!serverConfig?.alwaysAllow) {
return false;
}

return serverConfig.alwaysAllow.includes(toolName);
}

constructor() {
this.logger.debug('MCP client initialized');
}
Expand Down Expand Up @@ -306,15 +315,20 @@ export class MCPClient {
// dynamicTool is more explicit about unknown types compared to tool()
// MCP schemas come from external servers and are not known at compile time
const toolName = mcpTool.name;
const isAutoApproved = this.isToolAutoApproved(toolName, serverName);
const coreTool = dynamicTool({
description: mcpTool.description
? `[MCP:${serverName}] ${mcpTool.description}`
: `MCP tool from ${serverName}`,
inputSchema: jsonSchema<Record<string, unknown>>(
(mcpTool.inputSchema as unknown) || {type: 'object'},
),
// Medium risk: MCP tools require approval except in auto-accept mode
// Medium risk: MCP tools require approval unless explicitly configured in the server's alwaysAllow list or in auto-accept mode
needsApproval: () => {
if (isAutoApproved) {
return false;
}

const mode = getCurrentMode();
return mode !== 'auto-accept'; // true in normal/plan, false in auto-accept
},
Expand Down Expand Up @@ -608,6 +622,7 @@ export class MCPClient {
connected: boolean;
description?: string;
tags?: string[];
autoApprovedCommands?: string[];
}
| undefined {
const client = this.clients.get(serverName);
Expand All @@ -626,6 +641,7 @@ export class MCPClient {
connected: true,
description: serverConfig.description,
tags: serverConfig.tags,
autoApprovedCommands: serverConfig.alwaysAllow,
};
}
}
13 changes: 11 additions & 2 deletions source/tools/execute-bash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Box, Text} from 'ink';
import React from 'react';

import BashProgress from '@/components/bash-progress';
import {isNanocoderToolAlwaysAllowed} from '@/config/nanocoder-tools-config';
import {TRUNCATION_OUTPUT_LIMIT} from '@/constants';
import {useTheme} from '@/hooks/useTheme';
import {type BashExecutionState, bashExecutor} from '@/services/bash-executor';
Expand Down Expand Up @@ -75,8 +76,16 @@ const executeBashCoreTool = tool({
},
required: ['command'],
}),
// High risk: bash commands always require approval in all modes
needsApproval: true,
// High risk: bash commands require approval unless explicitly configured in nanocoderTools.alwaysAllow
needsApproval: () => {
// Check if this tool is configured to always be allowed
if (isNanocoderToolAlwaysAllowed('execute_bash')) {
return false;
}

// Even in auto-accept mode, bash commands should require approval for security
return true;
},
execute: async (args, _options) => {
return await executeExecuteBash(args);
},
Expand Down
8 changes: 7 additions & 1 deletion source/tools/string-replace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React from 'react';

import ToolMessage from '@/components/tool-message';
import {getColors} from '@/config/index';
import {isNanocoderToolAlwaysAllowed} from '@/config/nanocoder-tools-config';
import {getCurrentMode} from '@/context/mode-context';
import type {NanocoderToolExport} from '@/types/core';
import {jsonSchema, tool} from '@/types/core';
Expand Down Expand Up @@ -133,8 +134,13 @@ const stringReplaceCoreTool = tool({
},
required: ['path', 'old_str', 'new_str'],
}),
// Medium risk: file write operation, requires approval except in auto-accept mode
// Medium risk: file write operation, requires approval except in auto-accept mode or if configured in nanocoderTools.alwaysAllow
needsApproval: () => {
// Check if this tool is configured to always be allowed
if (isNanocoderToolAlwaysAllowed('string_replace')) {
return false;
}

const mode = getCurrentMode();
return mode !== 'auto-accept'; // true in normal/plan, false in auto-accept
},
Expand Down
Loading
Loading