Skip to content

Commit 4ae4021

Browse files
Copilotdata-douser
authored andcommitted
[UPDATE PRIMITIVE] Fix path traversal check, add prompt fixture runner, fix comment mismatch (#155)
1 parent cfc2586 commit 4ae4021

File tree

8 files changed

+173
-20
lines changed

8 files changed

+173
-20
lines changed

client/integration-tests/primitives/prompts/explain_codeql_query/invalid_query_path/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ when the user provides a `queryPath` that does not exist on disk.
1515

1616
## Parameters
1717

18-
| Parameter | Value | Notes |
19-
| ----------- | ------------------------------ | ------------------ |
20-
| `queryPath` | `nonexistent/path/to/query.ql` | Does **not** exist |
21-
| `language` | `javascript` | Valid language |
18+
| Parameter | Value | Notes |
19+
| -------------- | ------------------------------ | ------------------ |
20+
| `databasePath` | `nonexistent/path/to/database` | Does **not** exist |
21+
| `queryPath` | `nonexistent/path/to/query.ql` | Does **not** exist |
22+
| `language` | `javascript` | Valid language |
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
{
2-
"sessions": []
2+
"sessions": [],
3+
"parameters": {
4+
"databasePath": "nonexistent/path/to/database",
5+
"queryPath": "nonexistent/path/to/query.ql",
6+
"language": "javascript"
7+
}
38
}

client/src/lib/integration-test-runner.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ export class IntegrationTestRunner {
229229
// Also run workflow integration tests
230230
await this.runWorkflowIntegrationTests(baseDir);
231231

232+
// Also run prompt integration tests
233+
await this.runPromptIntegrationTests(baseDir);
234+
232235
return totalIntegrationTests > 0;
233236
} catch (error) {
234237
this.logger.log(`Error running integration tests: ${error.message}`, "ERROR");
@@ -1095,6 +1098,141 @@ export class IntegrationTestRunner {
10951098
return params;
10961099
}
10971100

1101+
/**
1102+
* Run prompt-level integration tests.
1103+
* Discovers test fixtures under `integration-tests/primitives/prompts/`
1104+
* and calls `client.getPrompt()` for each, validating the response.
1105+
*/
1106+
async runPromptIntegrationTests(baseDir) {
1107+
try {
1108+
this.logger.log("Discovering and running prompt integration tests...");
1109+
1110+
const promptTestsDir = path.join(baseDir, "..", "integration-tests", "primitives", "prompts");
1111+
1112+
if (!fs.existsSync(promptTestsDir)) {
1113+
this.logger.log("No prompt integration tests directory found", "INFO");
1114+
return true;
1115+
}
1116+
1117+
// Get list of available prompts from the server
1118+
const response = await this.client.listPrompts();
1119+
const prompts = response.prompts || [];
1120+
const promptNames = prompts.map((p) => p.name);
1121+
1122+
this.logger.log(`Found ${promptNames.length} prompts available on server`);
1123+
1124+
// Discover prompt test directories (each subdirectory = one prompt name)
1125+
const promptDirs = fs
1126+
.readdirSync(promptTestsDir, { withFileTypes: true })
1127+
.filter((dirent) => dirent.isDirectory())
1128+
.map((dirent) => dirent.name);
1129+
1130+
this.logger.log(
1131+
`Found ${promptDirs.length} prompt test directories: ${promptDirs.join(", ")}`
1132+
);
1133+
1134+
let totalPromptTests = 0;
1135+
for (const promptName of promptDirs) {
1136+
if (!promptNames.includes(promptName)) {
1137+
this.logger.log(`Skipping ${promptName} - prompt not found on server`, "WARN");
1138+
continue;
1139+
}
1140+
1141+
const promptDir = path.join(promptTestsDir, promptName);
1142+
const testCases = fs
1143+
.readdirSync(promptDir, { withFileTypes: true })
1144+
.filter((dirent) => dirent.isDirectory())
1145+
.map((dirent) => dirent.name);
1146+
1147+
this.logger.log(`Running ${testCases.length} test case(s) for prompt ${promptName}`);
1148+
1149+
for (const testCase of testCases) {
1150+
await this.runSinglePromptIntegrationTest(promptName, testCase, promptDir);
1151+
totalPromptTests++;
1152+
}
1153+
}
1154+
1155+
this.logger.log(`Total prompt integration tests executed: ${totalPromptTests}`);
1156+
return true;
1157+
} catch (error) {
1158+
this.logger.log(`Error running prompt integration tests: ${error.message}`, "ERROR");
1159+
return false;
1160+
}
1161+
}
1162+
1163+
/**
1164+
* Run a single prompt integration test.
1165+
*
1166+
* Reads parameters from `before/monitoring-state.json`, calls `getPrompt()`,
1167+
* and validates that the response contains messages (no protocol-level error).
1168+
*/
1169+
async runSinglePromptIntegrationTest(promptName, testCase, promptDir) {
1170+
const testName = `prompt:${promptName}/${testCase}`;
1171+
this.logger.log(`\nRunning prompt integration test: ${testName}`);
1172+
1173+
try {
1174+
const testCaseDir = path.join(promptDir, testCase);
1175+
const beforeDir = path.join(testCaseDir, "before");
1176+
const afterDir = path.join(testCaseDir, "after");
1177+
1178+
// Validate test structure
1179+
if (!fs.existsSync(beforeDir)) {
1180+
this.logger.logTest(testName, false, "Missing before directory");
1181+
return;
1182+
}
1183+
1184+
if (!fs.existsSync(afterDir)) {
1185+
this.logger.logTest(testName, false, "Missing after directory");
1186+
return;
1187+
}
1188+
1189+
// Load parameters from before/monitoring-state.json
1190+
const monitoringStatePath = path.join(beforeDir, "monitoring-state.json");
1191+
if (!fs.existsSync(monitoringStatePath)) {
1192+
this.logger.logTest(testName, false, "Missing before/monitoring-state.json");
1193+
return;
1194+
}
1195+
1196+
const monitoringState = JSON.parse(fs.readFileSync(monitoringStatePath, "utf8"));
1197+
const params = monitoringState.parameters || {};
1198+
resolvePathPlaceholders(params, this.logger);
1199+
1200+
// Call the prompt
1201+
this.logger.log(`Calling prompt ${promptName} with params: ${JSON.stringify(params)}`);
1202+
1203+
const result = await this.client.getPrompt({
1204+
name: promptName,
1205+
arguments: params
1206+
});
1207+
1208+
// Validate that the response contains messages (no raw protocol error)
1209+
const hasMessages = result.messages && result.messages.length > 0;
1210+
if (!hasMessages) {
1211+
this.logger.logTest(testName, false, "Expected messages in prompt response");
1212+
return;
1213+
}
1214+
1215+
// If the after/monitoring-state.json has expected content checks, validate
1216+
const afterMonitoringPath = path.join(afterDir, "monitoring-state.json");
1217+
if (fs.existsSync(afterMonitoringPath)) {
1218+
const afterState = JSON.parse(fs.readFileSync(afterMonitoringPath, "utf8"));
1219+
if (afterState.expectedContentPatterns) {
1220+
const text = result.messages[0]?.content?.text || "";
1221+
for (const pattern of afterState.expectedContentPatterns) {
1222+
if (!text.includes(pattern)) {
1223+
this.logger.logTest(testName, false, `Expected response to contain "${pattern}"`);
1224+
return;
1225+
}
1226+
}
1227+
}
1228+
}
1229+
1230+
this.logger.logTest(testName, true, `Prompt returned ${result.messages.length} message(s)`);
1231+
} catch (error) {
1232+
this.logger.logTest(testName, false, `Error: ${error.message}`);
1233+
}
1234+
}
1235+
10981236
/**
10991237
* Run workflow-level integration tests
11001238
* These tests validate complete workflows rather than individual tools

extensions/vscode/test/suite/mcp-prompt-e2e.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ suite('MCP Prompt Error Handling Integration Tests', () => {
300300
});
301301

302302
// ─────────────────────────────────────────────────────────────────────
303-
// sarif_rank_false_positives — optional sarifPath handling
303+
// sarif_rank_false_positives — sarifPath validation
304304
// ─────────────────────────────────────────────────────────────────────
305305

306306
test('sarif_rank_false_positives with nonexistent sarifPath should return warning', async function () {

server/dist/codeql-development-mcp-server.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34972,7 +34972,7 @@ var require_send = __commonJS({
3497234972
var join19 = path4.join;
3497334973
var normalize2 = path4.normalize;
3497434974
var resolve15 = path4.resolve;
34975-
var sep2 = path4.sep;
34975+
var sep3 = path4.sep;
3497634976
var BYTES_RANGE_REGEXP = /^ *bytes=/;
3497734977
var MAX_MAXAGE = 60 * 60 * 24 * 365 * 1e3;
3497834978
var UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
@@ -35133,22 +35133,22 @@ var require_send = __commonJS({
3513335133
var parts;
3513435134
if (root !== null) {
3513535135
if (path5) {
35136-
path5 = normalize2("." + sep2 + path5);
35136+
path5 = normalize2("." + sep3 + path5);
3513735137
}
3513835138
if (UP_PATH_REGEXP.test(path5)) {
3513935139
debug('malicious path "%s"', path5);
3514035140
this.error(403);
3514135141
return res;
3514235142
}
35143-
parts = path5.split(sep2);
35143+
parts = path5.split(sep3);
3514435144
path5 = normalize2(join19(root, path5));
3514535145
} else {
3514635146
if (UP_PATH_REGEXP.test(path5)) {
3514735147
debug('malicious path "%s"', path5);
3514835148
this.error(403);
3514935149
return res;
3515035150
}
35151-
parts = normalize2(path5).split(sep2);
35151+
parts = normalize2(path5).split(sep3);
3515235152
path5 = resolve15(path5);
3515335153
}
3515435154
if (containsDotFile(parts)) {
@@ -35242,7 +35242,7 @@ var require_send = __commonJS({
3524235242
var self = this;
3524335243
debug('stat "%s"', path5);
3524435244
fs3.stat(path5, function onstat(err, stat) {
35245-
var pathEndsWithSep = path5[path5.length - 1] === sep2;
35245+
var pathEndsWithSep = path5[path5.length - 1] === sep3;
3524635246
if (err && err.code === "ENOENT" && !extname3(path5) && !pathEndsWithSep) {
3524735247
return next(err);
3524835248
}
@@ -40549,8 +40549,8 @@ var require_adm_zip = __commonJS({
4054940549
return null;
4055040550
}
4055140551
function fixPath(zipPath) {
40552-
const { join: join19, normalize: normalize2, sep: sep2 } = pth.posix;
40553-
return join19(".", normalize2(sep2 + zipPath.split("\\").join(sep2) + sep2));
40552+
const { join: join19, normalize: normalize2, sep: sep3 } = pth.posix;
40553+
return join19(".", normalize2(sep3 + zipPath.split("\\").join(sep3) + sep3));
4055440554
}
4055540555
function filenameFilter(filterfn) {
4055640556
if (filterfn instanceof RegExp) {
@@ -64378,7 +64378,7 @@ function registerLanguageResources(server) {
6437864378
}
6437964379

6438064380
// src/prompts/workflow-prompts.ts
64381-
import { basename as basename7, isAbsolute as isAbsolute7, normalize, relative, resolve as resolve13 } from "path";
64381+
import { basename as basename7, isAbsolute as isAbsolute7, normalize, relative, resolve as resolve13, sep as sep2 } from "path";
6438264382
import { existsSync as existsSync12 } from "fs";
6438364383

6438464384
// src/prompts/check-for-duplicated-code.prompt.md
@@ -64478,7 +64478,7 @@ function resolvePromptFilePath(filePath, workspaceRoot) {
6447864478
const normalizedPath = normalize(filePath);
6447964479
const absolutePath = isAbsolute7(normalizedPath) ? normalizedPath : resolve13(effectiveRoot, normalizedPath);
6448064480
const rel = relative(effectiveRoot, absolutePath);
64481-
if (rel.startsWith("..") || isAbsolute7(rel)) {
64481+
if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute7(rel)) {
6448264482
return {
6448364483
resolvedPath: absolutePath,
6448464484
warning: `\u26A0 **File path** \`${filePath}\` **resolves outside the workspace root.** Resolved to: \`${absolutePath}\``

server/dist/codeql-development-mcp-server.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/prompts/workflow-prompts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
99
import { z } from 'zod';
10-
import { basename, isAbsolute, normalize, relative, resolve } from 'path';
10+
import { basename, isAbsolute, normalize, relative, resolve, sep } from 'path';
1111
import { existsSync } from 'fs';
1212
import { loadPromptTemplate, processPromptTemplate } from './prompt-loader';
1313
import { getUserWorkspaceDir } from '../utils/package-paths';
@@ -79,7 +79,7 @@ export function resolvePromptFilePath(
7979
// This catches path traversal (e.g. "../../etc/passwd") after full
8080
// resolution rather than relying on a fragile substring check.
8181
const rel = relative(effectiveRoot, absolutePath);
82-
if (rel.startsWith('..') || isAbsolute(rel)) {
82+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
8383
return {
8484
resolvedPath: absolutePath,
8585
warning: `⚠ **File path** \`${filePath}\` **resolves outside the workspace root.** Resolved to: \`${absolutePath}\``,

server/test/src/prompts/workflow-prompts.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,6 +1734,15 @@ describe('Workflow Prompts', () => {
17341734
expect(result.warning).toContain('resolves outside the workspace root');
17351735
});
17361736

1737+
it('should not flag filenames containing consecutive dots as traversal', () => {
1738+
const filePath = 'my..query.ql';
1739+
writeFileSync(join(testDir, filePath), 'select 1');
1740+
1741+
const result = resolvePromptFilePath(filePath, testDir);
1742+
expect(result.resolvedPath).toBe(join(testDir, filePath));
1743+
expect(result.warning).toBeUndefined();
1744+
});
1745+
17371746
it('should return the original path as resolvedPath even for invalid paths', () => {
17381747
const result = resolvePromptFilePath('nonexistent.ql', testDir);
17391748
expect(result.resolvedPath).toBe(join(testDir, 'nonexistent.ql'));

0 commit comments

Comments
 (0)