Skip to content

Commit 1f9e6b9

Browse files
Copilotdata-douser
andauthored
Address review thread 4071470992: path check, changelog, timeout help, error message, cross-platform test, output path sandboxing
Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/0666a5dd-b460-4e6e-9749-f729c46f0b62 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com>
1 parent 89cff05 commit 1f9e6b9

File tree

10 files changed

+248
-70
lines changed

10 files changed

+248
-70
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ _Changes on `main` since the latest tagged release that have not yet been includ
7979
- `McpProvider.requestRestart()` now invalidates the environment cache and bumps a `+rN` revision suffix so VS Code reliably restarts the MCP server after configuration changes. ([#196](https://github.com/advanced-security/codeql-development-mcp-server/pull/196))
8080
- Cached the extension version in the provider constructor to avoid repeated synchronous reads of `package.json`. ([#196](https://github.com/advanced-security/codeql-development-mcp-server/pull/196))
8181
- New `codeql-mcp.enableAnnotationTools` setting (default: `true`) auto-sets `ENABLE_ANNOTATION_TOOLS` and `MONITORING_STORAGE_LOCATION` environment variables; `additionalEnv` overrides for advanced users. ([#199](https://github.com/advanced-security/codeql-development-mcp-server/pull/199))
82-
- Simplified annotation tool environment: the extension no longer explicitly sets `ENABLE_ANNOTATION_TOOLS` since the server now defaults to `true`. ([#223](https://github.com/advanced-security/codeql-development-mcp-server/pull/223))
82+
- The earlier `codeql-mcp.enableAnnotationTools` setting is no longer applicable and has been removed from the extension as annotation tools are now enabled by default. ([#223](https://github.com/advanced-security/codeql-development-mcp-server/pull/223))
8383

8484
#### Infrastructure & CI/CD
8585

client/cmd/integration_tests.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func init() {
3838
f.StringVar(&integrationTestsFlags.tools, "tools", "", "Comma-separated list of tool names to test")
3939
f.StringVar(&integrationTestsFlags.tests, "tests", "", "Comma-separated list of test case names to run")
4040
f.BoolVar(&integrationTestsFlags.noInstall, "no-install-packs", false, "Skip CodeQL pack installation")
41-
f.IntVar(&integrationTestsFlags.timeout, "timeout", 0, "Per-tool-call timeout in seconds (0 = use server defaults)")
41+
f.IntVar(&integrationTestsFlags.timeout, "timeout", 0, "Per-tool-call timeout in seconds (0 = use client defaults)")
4242
}
4343

4444
// mcpToolCaller adapts the MCP client to the ToolCaller interface.

client/internal/mcp/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const (
4040
ConnectTimeout = 30 * time.Second
4141

4242
// CloseTimeoutErr is the error message returned when Close times out.
43-
CloseTimeoutErr = "MCP client close timed out after 3s; server subprocess may still be running"
43+
CloseTimeoutErr = "MCP client close timed out after 3s; attempted to kill server subprocess"
4444
)
4545

4646
// Config holds the configuration for connecting to an MCP server.

client/internal/mcp/client_test.go

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcp
22

33
import (
44
"context"
5+
"os"
56
"os/exec"
67
"strings"
78
"testing"
@@ -41,6 +42,18 @@ func (h *hangCloser) ListResources(_ context.Context, _ mcp.ListResourcesRequest
4142
return nil, nil
4243
}
4344

45+
// TestMain handles the subprocess helper mode used by
46+
// TestClose_KillsSubprocessOnTimeout. When GO_TEST_HANG_SUBPROCESS is set,
47+
// the process simply blocks forever, simulating a stuck MCP server on all
48+
// platforms (no dependency on an external `sleep` binary).
49+
func TestMain(m *testing.M) {
50+
if os.Getenv("GO_TEST_HANG_SUBPROCESS") == "1" {
51+
// Block forever so the parent test can verify force-kill behaviour.
52+
select {}
53+
}
54+
os.Exit(m.Run())
55+
}
56+
4457
func TestTimeoutForTool_CodeQLTools(t *testing.T) {
4558
codeqlTools := []string{
4659
"codeql_query_run",
@@ -113,18 +126,25 @@ func TestClose_TimeoutReturnsError(t *testing.T) {
113126
if CloseTimeoutErr == "" {
114127
t.Fatal("CloseTimeoutErr should not be empty")
115128
}
116-
if CloseTimeoutErr != "MCP client close timed out after 3s; server subprocess may still be running" {
129+
if CloseTimeoutErr != "MCP client close timed out after 3s; attempted to kill server subprocess" {
117130
t.Errorf("CloseTimeoutErr = %q, want specific timeout message", CloseTimeoutErr)
118131
}
119132
}
120133

121134
func TestClose_KillsSubprocessOnTimeout(t *testing.T) {
122135
t.Parallel()
123136

124-
// Start a long-running subprocess to simulate a stuck server.
125-
proc := exec.Command("sleep", "60")
137+
// Re-exec the current test binary as a subprocess that blocks forever.
138+
// This avoids a dependency on an external `sleep` binary (not available
139+
// on Windows) while still exercising the real process-kill path.
140+
exe, err := os.Executable()
141+
if err != nil {
142+
t.Fatalf("cannot determine test executable: %v", err)
143+
}
144+
proc := exec.Command(exe, "-test.run=^$") // run no tests; just reach TestMain
145+
proc.Env = append(os.Environ(), "GO_TEST_HANG_SUBPROCESS=1")
126146
if err := proc.Start(); err != nil {
127-
t.Skipf("cannot start subprocess for test: %v", err)
147+
t.Fatalf("cannot start subprocess for test: %v", err)
128148
}
129149

130150
hang := &hangCloser{released: make(chan struct{})}
@@ -135,17 +155,17 @@ func TestClose_KillsSubprocessOnTimeout(t *testing.T) {
135155
}
136156

137157
start := time.Now()
138-
err := c.Close()
158+
closeErr := c.Close()
139159
elapsed := time.Since(start)
140160

141161
// Release the hanging closer goroutine (cleanup).
142162
close(hang.released)
143163

144-
if err == nil {
164+
if closeErr == nil {
145165
t.Fatal("Close() should return a timeout error, got nil")
146166
}
147-
if !strings.Contains(err.Error(), "timed out") {
148-
t.Errorf("Close() error = %q; want a message containing 'timed out'", err.Error())
167+
if !strings.Contains(closeErr.Error(), "timed out") {
168+
t.Errorf("Close() error = %q; want a message containing 'timed out'", closeErr.Error())
149169
}
150170

151171
// Elapsed time should be roughly the 3-second timeout window.
@@ -162,10 +182,9 @@ func TestClose_KillsSubprocessOnTimeout(t *testing.T) {
162182

163183
select {
164184
case waitErr := <-waitCh:
165-
// Process exited (possibly as a zombie until Wait() is called).
166185
// A killed process returns an error from Wait().
167186
if waitErr == nil {
168-
t.Error("subprocess exited with success; expected it to be killed (non-zero exit)")
187+
t.Error("subprocess exited cleanly; expected it to be killed (non-zero exit)")
169188
}
170189
case <-time.After(2 * time.Second):
171190
_ = proc.Process.Kill()
@@ -183,4 +202,4 @@ func TestConstants(t *testing.T) {
183202
if ConnectTimeout != 30*time.Second {
184203
t.Errorf("ConnectTimeout = %v, want 30s", ConnectTimeout)
185204
}
186-
}
205+
}

client/internal/testing/runner.go

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,14 @@ func (r *Runner) runSingleTest(toolName, testCase, toolDir string) {
225225
// Resolve {{tmpdir}} placeholders in all params
226226
params = resolvePathPlaceholders(params, r.tmpBase)
227227

228-
// Clean up stale interpretedOutput from prior test runs so that
229-
// directory comparisons only see output from this invocation.
230-
cleanStaleOutput(toolName, params, ".")
228+
// Redirect bare relative output paths (output, interpretedOutput, outputDir)
229+
// to a per-test directory under tmpBase so that tool invocations do not
230+
// create artifacts in the process working directory (repo root).
231+
params = rewriteRelativeOutputPaths(params, r.tmpBase, toolName, testCase)
232+
233+
// Clean up stale output files from prior runs of THIS test so that
234+
// comparisons only see output produced by this invocation.
235+
cleanStaleOutput(toolName, params, r.tmpBase)
231236

232237
// Call the tool (using server tool name which may differ from fixture dir name)
233238
content, isError, callErr := r.caller.CallToolRaw(serverToolName, params)
@@ -444,10 +449,39 @@ func fileExists(path string) bool {
444449
return err == nil
445450
}
446451

452+
// rewriteRelativeOutputPaths rewrites bare relative file paths for the
453+
// "output", "interpretedOutput", and "outputDir" parameters to point inside a
454+
// per-test subdirectory of tmpBase. This prevents tool invocations from
455+
// creating artifacts in the process working directory (the repo root).
456+
//
457+
// Paths that are already absolute — whether constructed by buildToolParams or
458+
// resolved from a {{tmpdir}} placeholder — are left unchanged.
459+
func rewriteRelativeOutputPaths(params map[string]any, tmpBase, toolName, testCase string) map[string]any {
460+
outDir := filepath.Join(tmpBase, "test-output", toolName, testCase)
461+
result := make(map[string]any, len(params))
462+
for k, v := range params {
463+
switch k {
464+
case "output", "interpretedOutput", "outputDir":
465+
if s, ok := v.(string); ok && s != "" && !filepath.IsAbs(s) {
466+
if err := os.MkdirAll(outDir, 0o750); err != nil {
467+
fmt.Fprintf(os.Stderr, " warning: cannot create output dir %s: %v\n", outDir, err)
468+
}
469+
result[k] = filepath.Join(outDir, filepath.Base(s))
470+
continue
471+
}
472+
}
473+
result[k] = v
474+
}
475+
return result
476+
}
477+
447478
// cleanStaleOutput removes stale interpretedOutput files or directories from
448479
// prior codeql_query_run test invocations. This prevents stale results from
449-
// affecting directory comparisons. Only relative paths without directory
450-
// traversals are cleaned (CWE-22 prevention).
480+
// affecting directory comparisons.
481+
//
482+
// baseDir is used as the root for resolving relative paths. The function only
483+
// removes paths that resolve to within baseDir (CWE-22 prevention). Absolute
484+
// paths that are already within baseDir are cleaned directly.
451485
func cleanStaleOutput(toolName string, params map[string]any, baseDir string) {
452486
if toolName != "codeql_query_run" {
453487
return
@@ -461,17 +495,31 @@ func cleanStaleOutput(toolName string, params map[string]any, baseDir string) {
461495
return
462496
}
463497

464-
normalized := filepath.Clean(outputPath)
498+
// Resolve to an absolute path.
499+
var fullPath string
500+
if filepath.IsAbs(outputPath) {
501+
fullPath = outputPath
502+
} else {
503+
normalized := filepath.Clean(outputPath)
504+
// Reject bare directory traversals in relative paths.
505+
if normalized == ".." || strings.HasPrefix(normalized, ".."+string(filepath.Separator)) {
506+
fmt.Fprintf(os.Stderr, " Skipping interpretedOutput cleanup: unsafe path %q\n", outputPath)
507+
return
508+
}
509+
fullPath = filepath.Join(baseDir, normalized)
510+
}
465511

466-
// Reject absolute paths and directory traversals (CWE-22).
467-
if filepath.IsAbs(normalized) ||
468-
strings.HasPrefix(normalized, ".."+string(filepath.Separator)) ||
469-
normalized == ".." {
470-
fmt.Fprintf(os.Stderr, " Skipping interpretedOutput cleanup: unsafe path %q\n", outputPath)
512+
// Only remove paths within baseDir (CWE-22 prevention).
513+
absBase, err := filepath.Abs(baseDir)
514+
if err != nil {
515+
return
516+
}
517+
rel, err := filepath.Rel(absBase, fullPath)
518+
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
519+
fmt.Fprintf(os.Stderr, " Skipping interpretedOutput cleanup: path %q is outside base dir\n", outputPath)
471520
return
472521
}
473522

474-
fullPath := filepath.Join(baseDir, normalized)
475523
os.RemoveAll(fullPath)
476524
}
477525

client/internal/testing/runner_test.go

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package testing
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -66,6 +67,75 @@ func TestResolvePathPlaceholders(t *testing.T) {
6667
}
6768
}
6869

70+
func TestRewriteRelativeOutputPaths(t *testing.T) {
71+
dir := t.TempDir()
72+
73+
tests := []struct {
74+
name string
75+
params map[string]any
76+
wantAbsKey string
77+
wantBase string
78+
}{
79+
{
80+
name: "relative output redirected to tmpBase",
81+
params: map[string]any{"output": "query-results.bqrs"},
82+
wantAbsKey: "output",
83+
wantBase: filepath.Join(dir, "test-output", "codeql_query_run", "my_test"),
84+
},
85+
{
86+
name: "relative interpretedOutput redirected to tmpBase",
87+
params: map[string]any{"interpretedOutput": "query-results.sarif"},
88+
wantAbsKey: "interpretedOutput",
89+
wantBase: filepath.Join(dir, "test-output", "codeql_query_run", "my_test"),
90+
},
91+
{
92+
name: "absolute output left unchanged",
93+
params: map[string]any{"output": filepath.Join(dir, "after", "output.txt")},
94+
wantAbsKey: "",
95+
},
96+
{
97+
name: "non-output keys left unchanged",
98+
params: map[string]any{
99+
"query": "relative/query.ql",
100+
"database": "relative/db",
101+
},
102+
wantAbsKey: "",
103+
},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
result := rewriteRelativeOutputPaths(tt.params, dir, "codeql_query_run", "my_test")
109+
110+
if tt.wantAbsKey == "" {
111+
// Verify output-related keys are unchanged (or non-existent)
112+
for k, orig := range tt.params {
113+
if got, ok := result[k]; !ok || got != orig {
114+
t.Errorf("key %q: got %v, want %v (should be unchanged)", k, got, orig)
115+
}
116+
}
117+
return
118+
}
119+
120+
// Verify the key was rewritten to an absolute path under the expected base
121+
got, ok := result[tt.wantAbsKey]
122+
if !ok {
123+
t.Fatalf("key %q missing from result", tt.wantAbsKey)
124+
}
125+
gotStr, ok := got.(string)
126+
if !ok {
127+
t.Fatalf("key %q is not a string: %T", tt.wantAbsKey, got)
128+
}
129+
if !filepath.IsAbs(gotStr) {
130+
t.Errorf("rewritten path %q is not absolute", gotStr)
131+
}
132+
if !strings.HasPrefix(gotStr, tt.wantBase) {
133+
t.Errorf("rewritten path %q does not start with expected base %q", gotStr, tt.wantBase)
134+
}
135+
})
136+
}
137+
}
138+
69139
func TestToolPriority(t *testing.T) {
70140
tests := []struct {
71141
name string
@@ -408,19 +478,42 @@ func TestCleanStaleOutputRelativeDir(t *testing.T) {
408478
}
409479
}
410480

411-
func TestCleanStaleOutputRejectsAbsolutePath(t *testing.T) {
481+
func TestCleanStaleOutputAbsolutePathWithinBase(t *testing.T) {
482+
// After rewriteRelativeOutputPaths, interpretedOutput is an absolute path
483+
// within tmpBase. cleanStaleOutput should clean those paths.
412484
dir := t.TempDir()
413-
absFile := filepath.Join(dir, "safe-file")
414-
os.WriteFile(absFile, []byte("keep"), 0o600)
485+
staleFile := filepath.Join(dir, "test-output", "query-results.sarif")
486+
os.MkdirAll(filepath.Dir(staleFile), 0o755)
487+
os.WriteFile(staleFile, []byte("stale"), 0o600)
415488

416489
params := map[string]any{
417-
"interpretedOutput": absFile, // absolute path
490+
"interpretedOutput": staleFile, // absolute path inside dir
418491
}
419492

420493
cleanStaleOutput("codeql_query_run", params, dir)
421494

422-
if !fileExists(absFile) {
423-
t.Error("absolute path should NOT be removed")
495+
if fileExists(staleFile) {
496+
t.Error("absolute path within base dir should be removed")
497+
}
498+
}
499+
500+
func TestCleanStaleOutputRejectsAbsolutePathOutsideBase(t *testing.T) {
501+
// Absolute paths that are OUTSIDE baseDir must not be removed.
502+
dir := t.TempDir()
503+
outsideFile := filepath.Join(dir, "outside-file")
504+
os.WriteFile(outsideFile, []byte("keep"), 0o600)
505+
506+
innerBase := filepath.Join(dir, "inner")
507+
os.MkdirAll(innerBase, 0o755)
508+
509+
params := map[string]any{
510+
"interpretedOutput": outsideFile, // absolute path OUTSIDE innerBase
511+
}
512+
513+
cleanStaleOutput("codeql_query_run", params, innerBase)
514+
515+
if !fileExists(outsideFile) {
516+
t.Error("absolute path outside base dir should NOT be removed")
424517
}
425518
}
426519

0 commit comments

Comments
 (0)