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
6 changes: 6 additions & 0 deletions internal/shell/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ func TestDetectShellVersion_Branches(t *testing.T) {
}

func TestParentShellProcess_NonWindows(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping non-Windows test on Windows")
}
// On non-Windows, parentShellProcess is a no-op stub that returns "".
got := parentShellProcess()
if got != "" {
Expand All @@ -240,6 +243,9 @@ func TestParentShellProcess_NonWindows(t *testing.T) {
}

func TestPreferredPowerShell_NonWindows(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping non-Windows test on Windows")
}
// On non-Windows, preferredPowerShell returns "powershell".
got := preferredPowerShell()
if got != "powershell" {
Expand Down
29 changes: 24 additions & 5 deletions internal/tools/safety.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ var sensitiveEnvKeys = []string{

// ValidatePath resolves path to an absolute path and checks that it is
// contained within cwd and does not match any blocked pattern.
// It also resolves symlinks and re-validates the resolved path to prevent
// symlink attacks that could point outside cwd or to blocked files.
// Symlinks are resolved so that a link inside cwd pointing outside cannot
// bypass the containment check.
func ValidatePath(path, cwd string) (string, error) {
// Resolve relative to cwd
// Resolve relative to cwd lexically first
var abs string
if filepath.IsAbs(path) {
abs = filepath.Clean(path)
Expand All @@ -62,12 +62,31 @@ func ValidatePath(path, cwd string) (string, error) {

cwdClean := filepath.Clean(cwd)

// Ensure the path is under cwd
// Ensure the lexical path is under cwd
if abs != cwdClean && !strings.HasPrefix(abs, cwdClean+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q is outside the working directory", path)
Comment on lines 66 to 67

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.
}

// Check blocked patterns against the relative path
// 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)
Comment on lines +76 to +77

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 👍 / 👎.

}
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
}
Comment on lines +70 to +87

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 on lines +71 to +87

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.

// Check blocked patterns against the lexical relative path
rel, err := filepath.Rel(cwdClean, abs)
if err != nil {
return "", fmt.Errorf("cannot compute relative path: %w", err)
Expand Down
90 changes: 67 additions & 23 deletions internal/tools/safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@ package tools
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestValidatePath_Valid(t *testing.T) {
cwd := "/home/user/project"
var cwd string
if runtime.GOOS == "windows" {
cwd = "C:\\home\\user\\project"
} else {
cwd = "/home/user/project"
}

tests := []struct {
path string
want string
}{
{".", "/home/user/project"},
{"src", "/home/user/project/src"},
{"./src/main.go", "/home/user/project/src/main.go"},
{"src/../src/main.go", "/home/user/project/src/main.go"},
{".", cwd},
{"src", filepath.Join(cwd, "src")},
{"./src/main.go", filepath.Join(cwd, "src", "main.go")},
{"src/../src/main.go", filepath.Join(cwd, "src", "main.go")},
}
for _, tt := range tests {
got, err := ValidatePath(tt.path, cwd)
Expand All @@ -31,19 +38,30 @@ func TestValidatePath_Valid(t *testing.T) {
}

func TestValidatePath_OutsideCWD(t *testing.T) {
cwd := "/home/user/project"
paths := []string{
"../other",
"/etc/passwd",
"../../..",
var cwd string
if runtime.GOOS == "windows" {
cwd = "C:\\home\\user\\project"
} else {
cwd = "/home/user/project"
}
for _, path := range paths {
_, err := ValidatePath(path, cwd)

paths := []struct {
path string
err string
}{
{"../other", "outside"},
{"/etc/passwd", ""}, // On Windows this might be blocked or outside
{"../../..", "outside"},
}
for _, tt := range paths {
_, err := ValidatePath(tt.path, cwd)
if err == nil {
t.Errorf("ValidatePath(%q, %q) should have failed", path, cwd)
t.Errorf("ValidatePath(%q, %q) should have failed", tt.path, cwd)
continue
}
if !strings.Contains(err.Error(), "outside") {
t.Errorf("ValidatePath(%q) error = %q, want 'outside'", path, err.Error())
errMsg := strings.ToLower(err.Error())
if tt.err != "" && !strings.Contains(errMsg, tt.err) {
t.Errorf("ValidatePath(%q) error = %q, want %q", tt.path, err.Error(), tt.err)
}
}
}
Expand Down Expand Up @@ -98,6 +116,31 @@ func TestValidatePath_AllowedFiles(t *testing.T) {
}
}

func TestValidatePath_Symlink(t *testing.T) {
// Create a temporary directory to act as cwd
cwd := t.TempDir()
// Create a file outside cwd
outside := t.TempDir()
secretFile := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}

// 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.
t.Fatalf("Symlink: %v", err)
}

_, err := ValidatePath("link.txt", cwd)
if err == nil {
t.Error("ValidatePath with symlink escaping cwd should have failed")
}
if err != nil && !strings.Contains(err.Error(), "outside") {
t.Errorf("expected 'outside' error, got: %v", err)
}
}

func TestFilterEnvironment(t *testing.T) {
vars := []string{
"HOME=/home/user",
Expand All @@ -119,7 +162,7 @@ func TestFilterEnvironment(t *testing.T) {
"PATH": "/usr/bin",
"API_KEY": "[REDACTED]",
"AWS_SECRET_ACCESS_KEY": "[REDACTED]",
"DATABASE_URL": "postgres://localhost",
"DATABASE_URL": "[REDACTED]",
"AUTH_TOKEN": "[REDACTED]",
"MY_PASSWORD": "[REDACTED]",
"PRIVATE_DATA": "[REDACTED]",
Expand Down Expand Up @@ -157,7 +200,7 @@ func TestValidatePath_SymlinkOutsideCWD(t *testing.T) {
defer os.RemoveAll(outside)

target := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(target, []byte("secret"), 0600); err != nil {
if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -186,7 +229,7 @@ func TestValidatePath_SymlinkToBlockedFile(t *testing.T) {

// Create a blocked-pattern file inside cwd (e.g. id_rsa).
blocked := filepath.Join(cwd, "id_rsa")
if err := os.WriteFile(blocked, []byte("private key"), 0600); err != nil {
if err := os.WriteFile(blocked, []byte("private key"), 0o600); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -214,22 +257,23 @@ func TestValidatePath_SymlinkWithinCWD(t *testing.T) {
defer os.RemoveAll(cwd)

// Create a regular (non-blocked) file inside cwd.
real := filepath.Join(cwd, "main.go")
if err := os.WriteFile(real, []byte("package main"), 0600); err != nil {
mainFile := filepath.Join(cwd, "main.go")
if err := os.WriteFile(mainFile, []byte("package main"), 0o600); err != nil {
t.Fatal(err)
}

// Create a symlink inside cwd pointing to the file within cwd.
link := filepath.Join(cwd, "alias.go")
if err := os.Symlink(real, link); err != nil {
if err := os.Symlink(mainFile, link); err != nil {
t.Skip("symlinks not supported:", err)
}

got, err := ValidatePath("alias.go", cwd)
if err != nil {
t.Errorf("ValidatePath should allow symlink within cwd, got error: %v", err)
}
if got != link {
t.Errorf("ValidatePath returned %q, want %q", got, link)
want, _ := filepath.EvalSymlinks(link)
if got != filepath.Clean(want) {
t.Errorf("ValidatePath returned %q, want %q", got, want)
}
}
8 changes: 4 additions & 4 deletions internal/tools/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,11 @@ func execNetworkConnections() (string, error) {

var cmd *exec.Cmd
if runtime.GOOS == windowsOS {
cmd = exec.CommandContext(context.Background(), "powershell", "-Command", "Get-NetTCPConnection | Format-Table -AutoSize")
cmd = exec.CommandContext(ctx, "powershell", "-Command", "Get-NetTCPConnection | Format-Table -AutoSize")
} else if ssPath, err := exec.LookPath("ss"); err == nil {
cmd = exec.CommandContext(context.Background(), ssPath, "-an")
cmd = exec.CommandContext(ctx, ssPath, "-an")
} else if netstatPath, err := exec.LookPath("netstat"); err == nil {
cmd = exec.CommandContext(context.Background(), netstatPath, "-an")
cmd = exec.CommandContext(ctx, netstatPath, "-an")
} else {
return "", errors.New("network_connections failed: neither ss nor netstat found")
}
Expand Down Expand Up @@ -300,7 +300,7 @@ func execCheckCommand(command string) (string, error) {

path, err := exec.LookPath(command)
if err != nil {
return command + ": not found", nil
return "", fmt.Errorf("%s: not found: %w", command, err)
}

return path, nil
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/tools_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ func TestExecListMemories_WithEntries(t *testing.T) {
func TestExecListMemories_ParseError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

memDir := filepath.Join(home, ".ai-cli")
if err := os.MkdirAll(memDir, 0o755); err != nil {
Expand Down Expand Up @@ -448,6 +449,7 @@ func TestExecute_CommandHelp(t *testing.T) {
func TestExecute_ListMemories_WithConfigHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

memDir := filepath.Join(home, ".ai-cli")
if err := os.MkdirAll(memDir, 0o755); err != nil {
Expand Down
27 changes: 20 additions & 7 deletions internal/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,14 @@ func TestExecute_CheckCommand(t *testing.T) {

func TestExecute_CheckCommand_NotFound(t *testing.T) {
output, err := Execute("check_command", map[string]string{"command": "nonexistent_command_xyz"}, shell.Info{})
if err != nil {
t.Fatalf("Execute error: %v", err)
if err == nil {
t.Fatal("expected error for nonexistent command, got nil")
}
if output == "" {
t.Errorf("expected some output for nonexistent command, got empty string")
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %v", err)
}
if output != "" {
t.Errorf("expected empty output for nonexistent command, got: %q", output)
}
}

Expand All @@ -172,13 +175,23 @@ func TestExecute_Environment(t *testing.T) {
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")
}
if !strings.Contains(output, "TEST_API_KEY=[REDACTED]") {
// Test filtering logic directly with a controlled input (avoids output-truncation flakiness)
filtered := FilterEnvironment([]string{
"TEST_SAFE_VAR=visible",
"TEST_API_KEY=should_be_hidden",
})
filteredOut := strings.Join(filtered, "\n")
if !strings.Contains(filteredOut, "TEST_SAFE_VAR=visible") {
t.Error("expected safe var to be visible")
}
if !strings.Contains(filteredOut, "TEST_API_KEY=[REDACTED]") {
t.Error("expected API_KEY to show [REDACTED]")
}
}
Expand Down