Skip to content

Commit 4592243

Browse files
paul-maasclaude
andcommitted
fix(build-tools): Address PR review findings from Cursor Bugbot and Sentry
- Fix TOCTOU race in create_dmg: validateScriptPath now returns the resolved realScriptPath instead of discarding it, eliminating the second unguarded realpathSync call - Fix positional arg misordering in create_dmg: outputPath is only passed when appPath is also present, preventing wrong-slot injection - Fix unused bundleId in codesign_app: removed from .refine() check since xcrun notarytool submit does not accept --bundle-id - Fix validation ordering in pfctl_anchor: rulesFile extension check now runs before buildCommand, keeping unvalidated values out of the command array - Fix duplicate --port in serve-mcp.sh: filter --port from passthrough args before forwarding to supergateway - Deduplicate setStructuredOutput into command-result-helpers.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 048d4a3 commit 4592243

7 files changed

Lines changed: 112 additions & 97 deletions

File tree

scripts/serve-mcp.sh

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,24 @@ echo "Workflows: ${XCODEBUILDMCP_ENABLED_WORKFLOWS}"
5555
echo "PATH includes: $(which xcodegen 2>/dev/null || echo '(xcodegen not found)'), $(which codesign 2>/dev/null || echo '(codesign not found)')"
5656
echo ""
5757

58+
# Filter out --port from passthrough args to avoid duplication
59+
FILTERED_ARGS=()
60+
skip_next=false
61+
for arg in "$@"; do
62+
if $skip_next; then
63+
skip_next=false
64+
continue
65+
fi
66+
if [[ "$arg" == "--port" ]]; then
67+
skip_next=true
68+
continue
69+
fi
70+
FILTERED_ARGS+=("$arg")
71+
done
72+
5873
exec npx supergateway \
5974
--port "$PORT" \
6075
--stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \
6176
--outputTransport streamableHttp \
6277
--cors \
63-
"$@"
78+
"${FILTERED_ARGS[@]}"

src/mcp/tools/build-tools/__tests__/create_dmg.test.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,26 +52,30 @@ describe('create_dmg tool', () => {
5252

5353
describe('validateScriptPath', () => {
5454
it('should reject absolute scriptPath', () => {
55-
const error = _validateScriptPath('/usr/bin/evil', '/project');
56-
expect(error).toContain('must be relative');
55+
const result = _validateScriptPath('/usr/bin/evil', '/project');
56+
expect('error' in result).toBe(true);
57+
if ('error' in result) expect(result.error).toContain('must be relative');
5758
});
5859

5960
it('should reject path traversal', () => {
60-
const error = _validateScriptPath('../evil.sh', '/project');
61-
expect(error).toContain('path traversal');
61+
const result = _validateScriptPath('../evil.sh', '/project');
62+
expect('error' in result).toBe(true);
63+
if ('error' in result) expect(result.error).toContain('path traversal');
6264
});
6365

6466
it('should reject nested path traversal', () => {
65-
const error = _validateScriptPath('Scripts/../../evil.sh', '/project');
66-
expect(error).toContain('path traversal');
67+
const result = _validateScriptPath('Scripts/../../evil.sh', '/project');
68+
expect('error' in result).toBe(true);
69+
if ('error' in result) expect(result.error).toContain('path traversal');
6770
});
6871

6972
it('should return error when script not found', () => {
7073
mockedRealpathSync.mockImplementation(() => {
7174
throw new Error('ENOENT');
7275
});
73-
const error = _validateScriptPath('Scripts/create-dmg.sh', '/project');
74-
expect(error).toContain('Script not found');
76+
const result = _validateScriptPath('Scripts/create-dmg.sh', '/project');
77+
expect('error' in result).toBe(true);
78+
if ('error' in result) expect(result.error).toContain('Script not found');
7579
});
7680

7781
it('should return error when project path not found', () => {
@@ -80,8 +84,9 @@ describe('create_dmg tool', () => {
8084
if (pathStr === '/project/Scripts/create-dmg.sh') return pathStr;
8185
throw new Error('ENOENT');
8286
});
83-
const error = _validateScriptPath('Scripts/create-dmg.sh', '/project');
84-
expect(error).toContain('Project path not found');
87+
const result = _validateScriptPath('Scripts/create-dmg.sh', '/project');
88+
expect('error' in result).toBe(true);
89+
if ('error' in result) expect(result.error).toContain('Project path not found');
8590
});
8691

8792
it('should reject symlink escape', () => {
@@ -90,18 +95,21 @@ describe('create_dmg tool', () => {
9095
if (pathStr.includes('create-dmg.sh')) return '/outside/evil.sh';
9196
return '/project';
9297
});
93-
const error = _validateScriptPath('Scripts/create-dmg.sh', '/project');
94-
expect(error).toContain('symlink escape');
98+
const result = _validateScriptPath('Scripts/create-dmg.sh', '/project');
99+
expect('error' in result).toBe(true);
100+
if ('error' in result) expect(result.error).toContain('symlink escape');
95101
});
96102

97-
it('should accept valid script within project', () => {
103+
it('should accept valid script within project and return realScriptPath', () => {
98104
mockedRealpathSync.mockImplementation((p: fs.PathLike) => {
99105
const pathStr = String(p);
100106
if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh';
101107
return '/project';
102108
});
103-
const error = _validateScriptPath('Scripts/create-dmg.sh', '/project');
104-
expect(error).toBeNull();
109+
const result = _validateScriptPath('Scripts/create-dmg.sh', '/project');
110+
expect('realScriptPath' in result).toBe(true);
111+
if ('realScriptPath' in result)
112+
expect(result.realScriptPath).toBe('/project/Scripts/create-dmg.sh');
105113
});
106114
});
107115

@@ -213,6 +221,29 @@ describe('create_dmg tool', () => {
213221
expect(result.isError()).toBe(true);
214222
});
215223

224+
it('should not pass outputPath when appPath is omitted', async () => {
225+
mockedRealpathSync.mockImplementation((p: fs.PathLike) => {
226+
const pathStr = String(p);
227+
if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh';
228+
return '/project';
229+
});
230+
231+
let capturedCommand: string[] | undefined;
232+
const mockExecutor = createMockExecutor({
233+
success: true,
234+
output: '',
235+
onExecute: (cmd) => {
236+
capturedCommand = cmd;
237+
},
238+
});
239+
240+
await runToolLogic(() =>
241+
createDmgLogic({ projectPath: '/project', outputPath: '/dist/App.dmg' }, mockExecutor),
242+
);
243+
244+
expect(capturedCommand).toEqual(['/bin/sh', '/project/Scripts/create-dmg.sh']);
245+
});
246+
216247
it('should use default script path when scriptPath not provided', async () => {
217248
mockedRealpathSync.mockImplementation((p: fs.PathLike) => {
218249
const pathStr = String(p);

src/mcp/tools/build-tools/codesign_app.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as z from 'zod';
2-
import type { ToolHandlerContext } from '../../../rendering/types.ts';
32
import type { CommandExecutor } from '../../../utils/execution/index.ts';
43
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
54
import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts';
65
import {
7-
STRUCTURED_OUTPUT_SCHEMA,
6+
setStructuredOutput,
87
createCommandSuccess,
98
createCommandFailure,
109
} from './command-result-helpers.ts';
@@ -32,24 +31,13 @@ const codesignBaseSchema = z.object({
3231
bundleId: z.string().min(1).optional().describe('Bundle ID (required for notarization)'),
3332
});
3433

35-
const codesignSchema = codesignBaseSchema.refine(
36-
(val) => !val.notarize || (val.teamId != null && val.bundleId != null),
37-
{ message: 'teamId and bundleId are required when notarize is true', path: ['teamId'] },
38-
);
34+
const codesignSchema = codesignBaseSchema.refine((val) => !val.notarize || val.teamId != null, {
35+
message: 'teamId is required when notarize is true',
36+
path: ['teamId'],
37+
});
3938

4039
type CodesignParams = z.infer<typeof codesignSchema>;
4140

42-
function setStructuredOutput(
43-
ctx: ToolHandlerContext,
44-
result: ReturnType<typeof createCommandSuccess>,
45-
): void {
46-
ctx.structuredOutput = {
47-
result,
48-
schema: STRUCTURED_OUTPUT_SCHEMA,
49-
schemaVersion: '1',
50-
};
51-
}
52-
5341
export async function codesignLogic(
5442
params: CodesignParams,
5543
executor: CommandExecutor,

src/mcp/tools/build-tools/command-result-helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import type { CommandResultDomainResult, BasicDiagnostics } from '../../../types/domain-results.ts';
2+
import type { ToolHandlerContext } from '../../../rendering/types.ts';
23
import { createBasicDiagnostics } from '../../../utils/diagnostics.ts';
34

45
export const STRUCTURED_OUTPUT_SCHEMA = 'xcodebuildmcp.output.command-result';
56

7+
export function setStructuredOutput(
8+
ctx: ToolHandlerContext,
9+
result: CommandResultDomainResult,
10+
): void {
11+
ctx.structuredOutput = {
12+
result,
13+
schema: STRUCTURED_OUTPUT_SCHEMA,
14+
schemaVersion: '1',
15+
};
16+
}
17+
618
export function createCommandSuccess(
719
command: string,
820
output: string,

src/mcp/tools/build-tools/create_dmg.ts

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
33
import * as z from 'zod';
4-
import type { ToolHandlerContext } from '../../../rendering/types.ts';
54
import type { CommandExecutor } from '../../../utils/execution/index.ts';
65
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
76
import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts';
87
import {
9-
STRUCTURED_OUTPUT_SCHEMA,
8+
setStructuredOutput,
109
createCommandSuccess,
1110
createCommandFailure,
1211
} from './command-result-helpers.ts';
@@ -27,23 +26,15 @@ const createDmgSchema = z.object({
2726

2827
type CreateDmgParams = z.infer<typeof createDmgSchema>;
2928

30-
function setStructuredOutput(
31-
ctx: ToolHandlerContext,
32-
result: ReturnType<typeof createCommandSuccess>,
33-
): void {
34-
ctx.structuredOutput = {
35-
result,
36-
schema: STRUCTURED_OUTPUT_SCHEMA,
37-
schemaVersion: '1',
38-
};
39-
}
40-
41-
function validateScriptPath(resolvedScript: string, projectPath: string): string | null {
29+
function validateScriptPath(
30+
resolvedScript: string,
31+
projectPath: string,
32+
): { error: string } | { realScriptPath: string } {
4233
if (resolvedScript.startsWith('/')) {
43-
return `scriptPath must be relative, not absolute: ${resolvedScript}`;
34+
return { error: `scriptPath must be relative, not absolute: ${resolvedScript}` };
4435
}
4536
if (resolvedScript.includes('..')) {
46-
return `scriptPath must not contain path traversal (..): ${resolvedScript}`;
37+
return { error: `scriptPath must not contain path traversal (..): ${resolvedScript}` };
4738
}
4839

4940
const absoluteScriptPath = path.join(projectPath, resolvedScript);
@@ -52,24 +43,26 @@ function validateScriptPath(resolvedScript: string, projectPath: string): string
5243
try {
5344
realScriptPath = fs.realpathSync(absoluteScriptPath);
5445
} catch {
55-
return `Script not found: ${absoluteScriptPath}`;
46+
return { error: `Script not found: ${absoluteScriptPath}` };
5647
}
5748

5849
let realProjectPath: string;
5950
try {
6051
realProjectPath = fs.realpathSync(projectPath);
6152
} catch {
62-
return `Project path not found: ${projectPath}`;
53+
return { error: `Project path not found: ${projectPath}` };
6354
}
6455

6556
if (
6657
realScriptPath !== realProjectPath &&
6758
!realScriptPath.startsWith(realProjectPath + path.sep)
6859
) {
69-
return `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`;
60+
return {
61+
error: `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`,
62+
};
7063
}
7164

72-
return null;
65+
return { realScriptPath };
7366
}
7467

7568
export async function createDmgLogic(
@@ -80,20 +73,18 @@ export async function createDmgLogic(
8073
const resolvedScript = params.scriptPath ?? 'Scripts/create-dmg.sh';
8174
const commandLabel = `create-dmg (${resolvedScript})`;
8275

83-
const validationError = validateScriptPath(resolvedScript, params.projectPath);
84-
if (validationError != null) {
85-
const result = createCommandFailure(commandLabel, validationError);
76+
const validation = validateScriptPath(resolvedScript, params.projectPath);
77+
if ('error' in validation) {
78+
const result = createCommandFailure(commandLabel, validation.error);
8679
setStructuredOutput(ctx, result);
8780
return;
8881
}
8982

90-
const realScriptPath = fs.realpathSync(path.join(params.projectPath, resolvedScript));
91-
92-
const args: string[] = ['/bin/sh', realScriptPath];
83+
const args: string[] = ['/bin/sh', validation.realScriptPath];
9384
if (params.appPath != null) {
9485
args.push(params.appPath);
9586
}
96-
if (params.outputPath != null) {
87+
if (params.appPath != null && params.outputPath != null) {
9788
args.push(params.outputPath);
9889
}
9990

@@ -124,6 +115,7 @@ export async function createDmgLogic(
124115
}
125116

126117
export { validateScriptPath as _validateScriptPath };
118+
export type ValidateResult = ReturnType<typeof validateScriptPath>;
127119

128120
export const schema = createDmgSchema.shape;
129121

src/mcp/tools/build-tools/pfctl_anchor.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as z from 'zod';
2-
import type { ToolHandlerContext } from '../../../rendering/types.ts';
32
import type { CommandExecutor } from '../../../utils/execution/index.ts';
43
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
54
import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts';
65
import {
7-
STRUCTURED_OUTPUT_SCHEMA,
6+
setStructuredOutput,
87
createCommandSuccess,
98
createCommandFailure,
109
} from './command-result-helpers.ts';
@@ -32,17 +31,6 @@ const pfctlSchema = pfctlBaseSchema.refine(
3231

3332
type PfctlParams = z.infer<typeof pfctlSchema>;
3433

35-
function setStructuredOutput(
36-
ctx: ToolHandlerContext,
37-
result: ReturnType<typeof createCommandSuccess>,
38-
): void {
39-
ctx.structuredOutput = {
40-
result,
41-
schema: STRUCTURED_OUTPUT_SCHEMA,
42-
schemaVersion: '1',
43-
};
44-
}
45-
4634
function buildCommand(params: PfctlParams): string[] {
4735
const base = ['sudo', '-n', 'pfctl', '-a', params.anchorName];
4836

@@ -58,21 +46,22 @@ function buildCommand(params: PfctlParams): string[] {
5846

5947
export async function pfctlLogic(params: PfctlParams, executor: CommandExecutor): Promise<void> {
6048
const ctx = getHandlerContext();
61-
const command = buildCommand(params);
6249
const commandLabel = `pfctl -a ${params.anchorName} (${params.action})`;
6350

64-
try {
65-
if (params.action === 'test-syntax' && params.rulesFile != null) {
66-
if (!/\.(conf|rules)$/.test(params.rulesFile)) {
67-
const result = createCommandFailure(
68-
commandLabel,
69-
`rulesFile must end with .conf or .rules: ${params.rulesFile}`,
70-
);
71-
setStructuredOutput(ctx, result);
72-
return;
73-
}
51+
if (params.action === 'test-syntax' && params.rulesFile != null) {
52+
if (!/\.(conf|rules)$/.test(params.rulesFile)) {
53+
const result = createCommandFailure(
54+
commandLabel,
55+
`rulesFile must end with .conf or .rules: ${params.rulesFile}`,
56+
);
57+
setStructuredOutput(ctx, result);
58+
return;
7459
}
60+
}
7561

62+
const command = buildCommand(params);
63+
64+
try {
7665
const response = await executor(command, 'PF Anchor Inspection', false);
7766

7867
if (response.success) {

src/mcp/tools/build-tools/xcodegen_generate.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as z from 'zod';
2-
import type { ToolHandlerContext } from '../../../rendering/types.ts';
32
import type { CommandExecutor } from '../../../utils/execution/index.ts';
43
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
54
import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts';
65
import {
7-
STRUCTURED_OUTPUT_SCHEMA,
6+
setStructuredOutput,
87
createCommandSuccess,
98
createCommandFailure,
109
} from './command-result-helpers.ts';
@@ -16,17 +15,6 @@ const xcodegenSchema = z.object({
1615

1716
type XcodegenParams = z.infer<typeof xcodegenSchema>;
1817

19-
function setStructuredOutput(
20-
ctx: ToolHandlerContext,
21-
result: ReturnType<typeof createCommandSuccess>,
22-
): void {
23-
ctx.structuredOutput = {
24-
result,
25-
schema: STRUCTURED_OUTPUT_SCHEMA,
26-
schemaVersion: '1',
27-
};
28-
}
29-
3018
export async function xcodegenLogic(
3119
params: XcodegenParams,
3220
executor: CommandExecutor,

0 commit comments

Comments
 (0)