Skip to content

Fix ValidatePath symlink escape vulnerability in tool sandbox - #41

Merged
kriserickson merged 6 commits into
tool-callingfrom
copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c
Mar 2, 2026
Merged

kriserickson merged 6 commits into
tool-callingfrom
copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c

Conversation

Copilot AI commented Mar 1, 2026 •

Copy link
Copy Markdown
Contributor

ValidatePath used only lexical path normalization (filepath.Clean), so a symlink inside cwd pointing outside it (e.g. cwd/link → /etc) would pass the containment check and allow read_file/list_directory to access files outside the project tree.

Changes

  • internal/tools/safety.go — After lexical normalization, call filepath.EvalSymlinks on the resolved path and re-run both the cwd boundary check and blocked-pattern check on the real target. Falls back to the lexical path if EvalSymlinks fails (path doesn't exist; no symlink can exist for a non-existent path so lexical is sufficient).
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
    resolvedClean := filepath.Clean(resolved)
    if resolvedClean != cwdClean && !strings.HasPrefix(resolvedClean, cwdClean+string(os.PathSeparator)) {
        return "", fmt.Errorf("path %q is outside the working directory", path)
    }
    // re-check blocked patterns on resolved path ...
}
  • internal/tools/safety_test.go — Added TestValidatePath_Symlink using real temp dirs and a symlink to verify the escape is caught. Updated TestFilterEnvironment to expect DATABASE_URL=[REDACTED] (consistent with DATABASE already being in sensitiveEnvKeys).

  • internal/tools/tools_test.go — Fixed TestExecute_Environment which was flaky due to output truncation: the real environment exceeds the 4096-byte tool output limit, so vars appended by t.Setenv were silently dropped. Filtering assertions now use FilterEnvironment directly with controlled input.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Co-authored-by: kriserickson <325934+kriserickson@users.noreply.github.com>
Copilot AI changed the title [WIP] Add tool calling loop for LLM commands Fix ValidatePath symlink escape vulnerability in tool sandbox Mar 1, 2026
@kriserickson
kriserickson marked this pull request as ready for review March 1, 2026 20:30
Copilot AI review requested due to automatic review settings March 1, 2026 20:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22782213a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/tools/safety.go
Comment on lines +76 to +77
if resolvedClean != cwdClean && !strings.HasPrefix(resolvedClean, cwdClean+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q is outside the working directory", path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve cwd symlinks before comparing resolved target

When cwd itself is a symlink (e.g. user cds into /tmp/link where /tmp/link -> /tmp/real), filepath.EvalSymlinks(abs) returns /tmp/real/... but the new containment check still compares that against the lexical cwdClean (/tmp/link). This makes valid in-project paths fail as “outside the working directory,” breaking read_file/list_directory for symlinked working directories. Repro: ValidatePath("f.txt", "/tmp/link") now errors even though f.txt is inside the project; compare against a symlink-resolved cwd (or both forms) before rejecting.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the tool sandbox’s path validation to prevent symlink-based escapes from the working directory and adjusts tests to validate the new behavior and reduce environment-test flakiness.

Changes:

  • Update ValidatePath to resolve symlinks and re-check cwd containment (and blocked patterns) against the real target.
  • Add a symlink-based regression test for ValidatePath, and update env filtering expectations (DATABASE_URL redaction).
  • Rework the environment tool test to avoid reliance on truncated tool output by testing FilterEnvironment with controlled input.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
internal/tools/safety.go Adds symlink resolution to ValidatePath to prevent escaping cwd via symlinks.
internal/tools/safety_test.go Adds a symlink escape test and updates expected environment redaction behavior.
internal/tools/tools_test.go Makes TestExecute_Environment less flaky by avoiding assertions dependent on truncated output.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 171 to 181
output, err := Execute("environment", nil, shell.Info{})
if err != nil {
t.Fatalf("Execute error: %v", err)
}
if !strings.Contains(output, "TEST_SAFE_VAR=visible") {
t.Error("expected safe var to be visible")
if output == "" {
t.Error("expected non-empty environment output")
}
// Sensitive values must never appear in the raw form
if strings.Contains(output, "should_be_hidden") {
t.Error("expected API_KEY value to be redacted")
}

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestExecute_Environment still asserts redaction by scanning the (potentially truncated) tool output for the raw secret. That can be a false negative if the relevant line is truncated out, so the test may pass even if redaction regresses. Consider making the tool output deterministic by temporarily saving os.Environ(), calling os.Clearenv(), setting only a few vars for the duration of this test, and restoring the original environment in a t.Cleanup (or refactor execEnvironment to accept an env slice so it can be tested without relying on process env size/order).

Copilot uses AI. Check for mistakes.
Comment thread internal/tools/safety.go
Comment on lines +70 to +87
// Resolve symlinks so that a link inside cwd pointing outside is caught.
// If EvalSymlinks fails (e.g. the path does not exist yet), fall back to
// the lexically cleaned path: no symlink can exist for a non-existent path,
// so the lexical containment check above is sufficient in that case.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
resolvedClean := filepath.Clean(resolved)
if resolvedClean != cwdClean && !strings.HasPrefix(resolvedClean, cwdClean+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q is outside the working directory", path)
}
resolvedRel, relErr := filepath.Rel(cwdClean, resolvedClean)
if relErr != nil {
return "", fmt.Errorf("cannot compute relative path: %w", relErr)
}
if isBlocked(resolvedRel) {
return "", fmt.Errorf("access to %q is blocked for security", path)
}
return resolvedClean, nil
}

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When EvalSymlinks(abs) succeeds, the function returns before checking blocked patterns against the lexical relative path. This allows bypassing name-based blocks by creating a blocked-name symlink (e.g., .ssh or .env) inside cwd that points to an allowed file/dir, since only the resolved target is checked. Consider always running isBlocked(relLexical) first (based on the requested path under cwd), and then additionally checking isBlocked(resolvedRel) after symlink resolution.

Copilot uses AI. Check for mistakes.
Comment thread internal/tools/safety.go
Comment on lines +71 to +87
// If EvalSymlinks fails (e.g. the path does not exist yet), fall back to
// the lexically cleaned path: no symlink can exist for a non-existent path,
// so the lexical containment check above is sufficient in that case.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
resolvedClean := filepath.Clean(resolved)
if resolvedClean != cwdClean && !strings.HasPrefix(resolvedClean, cwdClean+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q is outside the working directory", path)
}
resolvedRel, relErr := filepath.Rel(cwdClean, resolvedClean)
if relErr != nil {
return "", fmt.Errorf("cannot compute relative path: %w", relErr)
}
if isBlocked(resolvedRel) {
return "", fmt.Errorf("access to %q is blocked for security", path)
}
return resolvedClean, nil
}

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment/logic that falls back to the lexical path when filepath.EvalSymlinks fails is not safe as written. A symlink can exist even if its target does not (dangling symlink), and EvalSymlinks can also fail for reasons other than non-existence (permissions, broken components), which would re-enable the original symlink-escape behavior by returning abs. Consider: (1) handling os.IsNotExist(err) separately, (2) using os.Lstat to detect whether any existing path component is a symlink, and/or (3) denying access when symlink resolution fails (since read/list operations require an existing target anyway).

Copilot uses AI. Check for mistakes.
Comment thread internal/tools/safety.go
Comment on lines 66 to 67
if abs != cwdClean && !strings.HasPrefix(abs, cwdClean+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q is outside the working directory", path)

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strings.HasPrefix(abs, cwdClean+string(os.PathSeparator)) containment check breaks when cwdClean is a filesystem root (e.g. / on Unix or C:\ on Windows), because appending an extra separator changes the prefix (// or C:\\) and valid in-cwd paths will be rejected. Consider switching to a filepath.Rel-based check (reject if rel starts with ..) or special-casing volume/root paths when building the prefix.

Copilot uses AI. Check for mistakes.

// Create a symlink inside cwd pointing to the file outside cwd
symlink := filepath.Join(cwd, "link.txt")
if err := os.Symlink(secretFile, symlink); err != nil {

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.Symlink is not reliably available on all platforms/CI setups (notably Windows often requires admin or Developer Mode). As written, this test will hard-fail in those environments. Consider skipping the test when symlinks aren’t supported (e.g., gate on runtime.GOOS == "windows" or errors.Is(err, syscall.EPERM) / os.IsPermission(err) from os.Symlink).

Suggested change
if err := os.Symlink(secretFile, symlink); err != nil {
if err := os.Symlink(secretFile, symlink); err != nil {
if os.IsPermission(err) {
t.Skipf("skipping symlink test: symlinks not permitted on this platform/CI: %v", err)
}

Copilot uses AI. Check for mistakes.
kriserickson and others added 4 commits March 1, 2026 16:32
…erShell; update cwd handling in TestValidatePath for cross-platform compatibility; set USERPROFILE in memory tests; improve error handling in TestExecute_CheckCommand_NotFound
@kriserickson
kriserickson merged commit 06a3354 into tool-calling Mar 2, 2026
3 checks passed
@kriserickson
kriserickson deleted the copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c branch March 2, 2026 02:42
@kriserickson
kriserickson restored the copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c branch March 2, 2026 21:16
@kriserickson
kriserickson deleted the copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c branch March 2, 2026 21:16
@kriserickson
kriserickson restored the copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c branch March 2, 2026 21:17
@kriserickson
kriserickson deleted the copilot/sub-pr-28-e187be21-d8ea-447d-a6a4-be63004e459c branch March 18, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants