Skip to content

Commit 783a11a

Browse files
committed
fix(security): address config lockdown review findings
- Log leakage: remove valueToStore content from console.error at parse and success paths; replace with type/key metadata only. Also suppress the full config dump in getConfig() (key count only). - Misleading editable flag: derive editable from !securityCritical so the config schema correctly reports security-critical keys as non-editable via the MCP tool. - Auth bypass (opt-in hardening): add DESKTOP_COMMANDER_UI_TOKEN env-var enforcement for _internal_set_config_value. When the host sets this var, callers must supply a matching _uiToken in args (timing-safe comparison). The config-editor UI reads window.__DC_UI_TOKEN injected by the hosting app's preload and passes it automatically. When the env var is unset the behaviour is unchanged for backward compatibility. - Docstrings: add JSDoc to pathExists, detectAvailableShells (config.ts), listProcesses, killProcess (process.ts), and isConfigFieldKey (config-field-definitions.ts) to restore docstring coverage above 80%.
1 parent 96a9412 commit 783a11a

5 files changed

Lines changed: 74 additions & 5 deletions

File tree

src/config-field-definitions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;
5454

5555
export const CONFIG_FIELD_KEYS = Object.keys(CONFIG_FIELD_DEFINITIONS) as ConfigFieldKey[];
5656

57+
/**
58+
* Type guard that checks whether `value` is a valid config field key.
59+
* Used to validate tool-argument keys before accessing CONFIG_FIELD_DEFINITIONS.
60+
*/
5761
export function isConfigFieldKey(value: string): value is ConfigFieldKey {
5862
return Object.prototype.hasOwnProperty.call(CONFIG_FIELD_DEFINITIONS, value);
5963
}

src/server.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ import {
1313
type InitializeRequest,
1414
} from "@modelcontextprotocol/sdk/types.js";
1515
import { zodToJsonSchema } from "zod-to-json-schema";
16+
import { timingSafeEqual } from 'crypto';
1617
import { getSystemInfo, getOSSpecificGuidance, getPathGuidance, getDevelopmentToolGuidance } from './utils/system-info.js';
1718

19+
// Optional per-session token for the config-editor UI.
20+
// When the env var DESKTOP_COMMANDER_UI_TOKEN is set by the hosting app, callers of
21+
// _internal_set_config_value must supply the matching value in args._uiToken. The
22+
// hosting app is responsible for injecting the same token into the UI webview (e.g.
23+
// via window.__DC_UI_TOKEN in an Electron preload script).
24+
// If the env var is NOT set, token validation is skipped for backward compatibility.
25+
const UI_SESSION_TOKEN: string | null = process.env.DESKTOP_COMMANDER_UI_TOKEN ?? null;
26+
1827
// Get system information once at startup
1928
const SYSTEM_INFO = getSystemInfo();
2029
const OS_GUIDANCE = getOSSpecificGuidance(SYSTEM_INFO);
@@ -1242,7 +1251,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
12421251
// Internal-only handler for the config editor UI. Not listed in
12431252
// the tools catalog, so AI agents cannot discover or call it.
12441253
// The 'ui' callerOrigin allows security-critical keys to be changed.
1245-
case "_internal_set_config_value":
1254+
// A per-session token (UI_SESSION_TOKEN) provides a second layer of
1255+
// defence: callers must supply the token in args._uiToken. The
1256+
// hosting app is responsible for injecting the token into the UI.
1257+
case "_internal_set_config_value": {
1258+
// Validate the per-session UI token when enforcement is active.
1259+
// Enforcement is opt-in: set DESKTOP_COMMANDER_UI_TOKEN in the
1260+
// server environment to enable it. The hosting app must also
1261+
// inject the same token into the UI webview (window.__DC_UI_TOKEN).
1262+
if (UI_SESSION_TOKEN !== null) {
1263+
const internalArgs = (args ?? {}) as Record<string, unknown>;
1264+
const providedToken = typeof internalArgs._uiToken === 'string'
1265+
? internalArgs._uiToken : '';
1266+
const expectedBuf = Buffer.from(UI_SESSION_TOKEN);
1267+
const providedBuf = Buffer.allocUnsafe(expectedBuf.length);
1268+
providedBuf.fill(0);
1269+
Buffer.from(providedToken).copy(providedBuf, 0, 0, expectedBuf.length);
1270+
const tokenValid = providedToken.length === UI_SESSION_TOKEN.length
1271+
&& timingSafeEqual(expectedBuf, providedBuf);
1272+
if (!tokenValid) {
1273+
capture('server_request_error', { message: 'Rejected _internal_set_config_value: invalid or missing UI session token' });
1274+
result = {
1275+
content: [{ type: "text", text: `Error: Unauthorized` }],
1276+
isError: true,
1277+
};
1278+
break;
1279+
}
1280+
}
12461281
try {
12471282
result = await setConfigValue(args, 'ui');
12481283
} catch (error) {
@@ -1253,6 +1288,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
12531288
};
12541289
}
12551290
break;
1291+
}
12561292

12571293
case "get_usage_stats":
12581294
try {

src/tools/config.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414

1515
const ALLOWED_CONFIG_KEYS = new Set(CONFIG_FIELD_KEYS);
1616

17+
/**
18+
* Returns true if the given path is accessible and executable.
19+
* Used to probe shell binary availability before reporting them in config.
20+
*/
1721
async function pathExists(pathValue: string): Promise<boolean> {
1822
try {
1923
await access(pathValue, fsConstants.X_OK);
@@ -23,6 +27,13 @@ async function pathExists(pathValue: string): Promise<boolean> {
2327
}
2428
}
2529

30+
/**
31+
* Probes for shells available on the current system by testing known binary
32+
* paths from the system-info default shell and a fixed candidate list.
33+
*
34+
* @param systemInfo System information object returned by getSystemInfo().
35+
* @returns Sorted, deduplicated array of absolute shell paths that exist on disk.
36+
*/
2637
async function detectAvailableShells(systemInfo: ReturnType<typeof getSystemInfo>): Promise<string[]> {
2738
const detected = new Set<string>();
2839
const add = (shell: string): void => {
@@ -117,7 +128,7 @@ export async function getConfig() {
117128
};
118129
const availableShells = await detectAvailableShells(systemInfo);
119130

120-
console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
131+
console.error(`getConfig: returning config with ${CONFIG_FIELD_KEYS.length} keys`);
121132
return {
122133
content: [{
123134
type: "text",
@@ -135,7 +146,7 @@ export async function getConfig() {
135146
key,
136147
value,
137148
valueType: definition.valueType,
138-
editable: true,
149+
editable: !(definition.securityCritical ?? false),
139150
securityCritical: definition.securityCritical ?? false,
140151
};
141152
}),
@@ -215,7 +226,7 @@ export async function setConfigValue(args: unknown, callerOrigin: 'mcp' | 'ui' =
215226
(valueToStore.startsWith('[') || valueToStore.startsWith('{'))) {
216227
try {
217228
valueToStore = JSON.parse(valueToStore);
218-
console.error(`Parsed string value to object/array: ${JSON.stringify(valueToStore)}`);
229+
console.error(`Parsed string value to object/array for key ${parsed.data.key} (type: ${typeof valueToStore})`);
219230
} catch (parseError) {
220231
console.error(`Failed to parse string as JSON, using as-is: ${parseError}`);
221232
}
@@ -272,7 +283,7 @@ export async function setConfigValue(args: unknown, callerOrigin: 'mcp' | 'ui' =
272283
await configManager.setValue(parsed.data.key, valueToStore);
273284
// Get the updated configuration to show the user
274285
const updatedConfig = await configManager.getConfig();
275-
console.error(`setConfigValue: Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore)}`);
286+
console.error(`setConfigValue: Successfully set ${parsed.data.key} (type: ${typeof valueToStore}, isArray: ${Array.isArray(valueToStore)})`);
276287
return {
277288
content: [{
278289
type: "text",

src/tools/process.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { terminalManager } from '../terminal-manager.js';
77

88
const execAsync = promisify(exec);
99

10+
/**
11+
* Lists all running system processes with their PID, command, CPU, and memory usage.
12+
* Uses `ps aux` on Unix and `tasklist` on Windows.
13+
*/
1014
export async function listProcesses(): Promise<ServerResult> {
1115
const command = os.platform() === 'win32' ? 'tasklist' : 'ps aux';
1216
try {
@@ -40,6 +44,13 @@ export async function listProcesses(): Promise<ServerResult> {
4044
}
4145
}
4246

47+
/**
48+
* Terminates a process started via Desktop Commander's start_process tool.
49+
* Only processes tracked by the internal terminal manager can be killed,
50+
* preventing the AI agent from terminating arbitrary system processes.
51+
*
52+
* @param args Tool arguments containing the target PID. Parsed via KillProcessArgsSchema.
53+
*/
4354
export async function killProcess(args: unknown): Promise<ServerResult> {
4455
const parsed = KillProcessArgsSchema.safeParse(args);
4556
if (!parsed.success) {

src/ui/config-editor/src/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,16 @@ export function createConfigEditorController(callTool: ToolCall, trackConfigUiEv
435435
}
436436

437437
try {
438+
// Include the UI session token when the hosting app has injected it
439+
// (window.__DC_UI_TOKEN). The server validates this token when
440+
// DESKTOP_COMMANDER_UI_TOKEN is set in its environment.
441+
const uiToken = (typeof window !== 'undefined' && (window as any).__DC_UI_TOKEN)
442+
? String((window as any).__DC_UI_TOKEN)
443+
: undefined;
438444
const setResult = await callTool('_internal_set_config_value', {
439445
key: selected.key,
440446
value: parsed.value,
447+
...(uiToken !== undefined ? { _uiToken: uiToken } : {}),
441448
});
442449

443450
if (isToolErrorResult(setResult)) {

0 commit comments

Comments
 (0)