Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Argument completions for prompts and resource templates, tasks on MCP 2026-07-28

### Security

- The copy-paste `run: mcpc ...` hints no longer let a server-chosen name or URI run a command when pasted. Values containing a backslash followed by `$`, a backtick or `"` (for example `\$(id)`) were double-quoted without escaping the backslash, so a POSIX shell still expanded them. Backslashes are now escaped like the other characters that stay special inside double quotes. (GHSA-wmqm-r8wm-9f34)
- `login --grant client-credentials` and `login --grant id-jag` now bind the stored client secret or key to the authorization server they were validated against, so a server that later points at a different authorization server is refused instead of receiving the credentials. Profiles created earlier keep working; log in again to bind them.
- The `env` of a stdio config entry, where API tokens such as `GITHUB_PERSONAL_ACCESS_TOKEN` usually live, is now stored in the OS keychain and delivered to the bridge over IPC like session headers, instead of being written in plaintext to `sessions.json`, printed by `mcpc --json` and `mcpc @session`, and passed on the bridge's command line where `ps` showed it to every local user. Verbose output and the bridge log no longer record header values, env values, or tool-call arguments either, and bridge log files are created owner-only. (#341)

Expand Down
7 changes: 6 additions & 1 deletion src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@
);
}
if (options?.sessionName) {
const first = supporting[0]!;

Check warning on line 1063 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 24

Forbidden non-null assertion

Check warning on line 1063 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 26

Forbidden non-null assertion

Check warning on line 1063 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 22

Forbidden non-null assertion
const name = first.uri.startsWith(`${root}/`)
? first.uri.slice(root.length + 1)
: first.uri;
Expand Down Expand Up @@ -1722,7 +1722,12 @@
? /^[A-Za-z0-9_./:@%+,=~\\-]+$/.test(value)
: /^[A-Za-z0-9_./:@%+,=~-]+$/.test(value);
if (isSafe) return value;
return `"${value.replace(/(["`$])/g, '\\$&')}"`;
// Inside POSIX double quotes `$`, a backtick, `"` and `\` stay special. The backslash must be
// escaped too: left alone, `\$(id)` would render as `\\$(id)`, where `\\` is a literal backslash
// and `$(id)` still runs, and a trailing backslash would escape the closing quote. On Windows
// the backslash is the path separator and is passed through unchanged.
const special = platform === 'win32' ? /(["`$])/g : /(["`$\\])/g;
return `"${value.replace(special, '\\$&')}"`;
}

/**
Expand Down
39 changes: 38 additions & 1 deletion test/unit/cli/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { MockInstance } from 'vitest';
import { execFileSync } from 'node:child_process';
import { extractAllTextContent } from '../../../src/cli/tool-result.js';

// Mock chalk to return plain strings (required because Jest can't handle chalk's ESM imports)
Expand Down Expand Up @@ -2530,6 +2531,42 @@ describe('quoteShellArg', () => {
expect(quoteShellArg('a$(id)b')).toBe('"a\\$(id)b"');
expect(quoteShellArg('a`id`b')).toBe('"a\\`id\\`b"');
});

it('escapes backslashes so they cannot neutralize the escaping of `$` or the closing quote', () => {
// Unescaped, `\$(id)` would render as `"\\$(id)"`: a literal backslash followed by a live
// command substitution.
expect(quoteShellArg('\\$(id)', 'linux')).toBe('"\\\\\\$(id)"');
expect(quoteShellArg('a b\\', 'linux')).toBe('"a b\\\\"');
expect(quoteShellArg('\\`id`', 'linux')).toBe('"\\\\\\`id\\`"');
expect(quoteShellArg('x\\"; id; "', 'linux')).toBe('"x\\\\\\"; id; \\""');
});

it.skipIf(process.platform === 'win32')(
'round-trips hostile values through a real POSIX shell as one literal argument',
() => {
const hostile = [
'\\$(echo PWNED)',
'$(echo PWNED)',
'`echo PWNED`',
'a b\\',
'x\\"; echo PWNED; "',
'\\\\$(echo PWNED)',
'pdf; echo PWNED #',
'C:\\Program Files\\x $HOME',
];
for (const value of hostile) {
const out = execFileSync('sh', ['-c', `printf %s ${quoteShellArg(value, 'linux')}`], {
encoding: 'utf8',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin' },
});
expect(out).toBe(value);
}
}
);

it('leaves backslashes alone on Windows, where they separate path segments', () => {
expect(quoteShellArg('C:\\Program Files\\x', 'win32')).toBe('"C:\\Program Files\\x"');
});
});

describe('formatPath', () => {
Expand All @@ -2555,7 +2592,7 @@ describe('formatPath', () => {
});

it('quotes backslash paths on POSIX, where backslash is a shell escape character', () => {
expect(formatPath('/tmp/a\\b/mcp.json', 'linux')).toBe('"/tmp/a\\b/mcp.json"');
expect(formatPath('/tmp/a\\b/mcp.json', 'linux')).toBe('"/tmp/a\\\\b/mcp.json"');
});

it('escapes characters that stay special inside double quotes', () => {
Expand Down
Loading