From ba98c0a21f482f13fb856c7ac44d71b274bbaeb1 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 11 Jun 2026 12:03:04 -0600 Subject: [PATCH] fix(sandbox): handle symlinks in allowed paths on Linux and macOS Symlinked allowRead/allowWrite entries previously broke inside the sandbox: on Linux, deny-by-default mode bound only the resolved target (or silently skipped symlink glob matches), so programs opening the original link path got ENOENT. Recreate the link with bwrap --symlink and bind its resolved target so both paths work. On macOS, emit Seatbelt rules for resolved targets of symlinked shell configs, home caches, and glob matches, since Seatbelt evaluates resolved paths. Symlinks inside allowed directories that point outside them now get their targets exposed read-only, controlled by the new filesystem.symlinkScan option: "shallow" (default, direct entries only), "deep" (recursive, capped), or "off". The scan never auto-exposes credential locations (SensitiveUserDirs); those still require an explicit allowRead grant, and denyRead continues to override anything the scan exposes. Denied symlinked paths now mask the resolved target so denied content cannot leak through the link. ExpandGlobPatterns moved to a platform-neutral file so glob expansion works on darwin instead of being stubbed out. Fixes #91 --- docs/configuration.md | 29 +++ docs/troubleshooting.md | 16 ++ internal/config/config.go | 22 +++ internal/config/config_test.go | 15 ++ internal/sandbox/dangerous.go | 17 ++ internal/sandbox/glob.go | 161 +++++++++++++++++ internal/sandbox/linux.go | 224 ++++++++++++++++++++---- internal/sandbox/linux_landlock.go | 152 ---------------- internal/sandbox/linux_landlock_stub.go | 5 - internal/sandbox/macos.go | 64 ++++++- internal/sandbox/mounts_linux_test.go | 116 ++++++++++++ internal/sandbox/symlinks.go | 135 ++++++++++++++ internal/sandbox/symlinks_test.go | 209 ++++++++++++++++++++++ internal/sandbox/utils.go | 24 ++- 14 files changed, 984 insertions(+), 205 deletions(-) create mode 100644 internal/sandbox/glob.go create mode 100644 internal/sandbox/symlinks.go create mode 100644 internal/sandbox/symlinks_test.go diff --git a/docs/configuration.md b/docs/configuration.md index fe0a861..57695c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,9 +115,38 @@ Greywall routes all network traffic through an external SOCKS5 proxy. Domain fil | `allowWrite` | Paths to allow writing | | `denyWrite` | Paths to deny writing (takes precedence over `allowWrite`) | | `allowGitConfig` | Allow writes to `.git/config` files | +| `symlinkScan` | How allowed directories are scanned for symlinks pointing outside them: `"shallow"` (default), `"deep"`, or `"off"` | To opt out of deny-by-default reads, set `"defaultDenyRead": false`. Use `--learning` mode to automatically discover which paths a command needs. See [Learning Mode](./learning-mode). +### Symlinks in Allowed Paths + +Allowed paths that are symlinks work transparently: greywall recreates the link inside the sandbox and exposes its resolved target, so programs can open either the link path or the real path. This covers common dotfiles setups where files like `~/.claude.json` are symlinks into another directory. + +Symlinks *inside* an allowed directory that point outside it need their targets exposed too. The `symlinkScan` option controls how greywall finds them: + +| Mode | Behavior | +|------|----------| +| `shallow` (default) | Scans only the direct entries of each allowed directory. One directory read per allowed path, negligible startup cost. | +| `deep` | Recursively walks each allowed directory (capped at 10,000 entries per directory). Use when allowed directories contain symlinks in nested subdirectories. Slows startup on large directory trees. | +| `off` | No scanning. Symlink targets inside allowed directories must be added to `allowRead` explicitly. | + +The scan applies to `allowRead` directories on both Linux (bwrap binds) and macOS (Seatbelt rules). Discovered targets are exposed read-only. + +As a safeguard, the scan never auto-exposes credential-bearing locations: `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`, `~/.docker`, `~/.netrc`, `~/.git-credentials`, `~/.password-store`, `~/.config/gcloud`, and `.env` files. This built-in list is not configurable, but both directions are under your control through existing options: + +- To expose one of these paths anyway, add it to `allowRead` explicitly. Explicit grants bypass the scan safeguard. +- To keep additional paths out of the sandbox, add them to `denyRead`. Deny rules are applied after all allow rules, so they also override anything the scan exposes. + +```json +{ + "filesystem": { + "allowRead": ["~/.claude"], + "symlinkScan": "deep" + } +} +``` + ## Command Configuration Block specific commands from being executed, even within command chains. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 62e5c79..9a6778a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -126,6 +126,22 @@ Common causes: - Localhost outbound blocked (DB/cache on `127.0.0.1`) - Writes blocked (you didn't include a directory in `filesystem.allowWrite`) +## Symlinked config files not found inside the sandbox + +If a path in `filesystem.allowRead` is a symlink (common with dotfiles managers like stow), greywall recreates the link inside the sandbox and exposes its resolved target, so this works out of the box. + +If the symlink sits *inside* an allowed directory and points outside it, greywall must also expose the target. By default it scans only the direct entries of each allowed directory (`"symlinkScan": "shallow"`). For symlinks in nested subdirectories, set `"symlinkScan": "deep"` in the `filesystem` section, or add the symlink target to `allowRead` directly: + +```json +{ + "filesystem": { + "allowRead": ["~/.claude", "~/dotfiles/claude"] + } +} +``` + +Targets under credential-bearing locations (`~/.ssh`, `~/.aws`, `.env` files, and similar) are never exposed by the scan; they always need an explicit `allowRead` entry. Run with `-d` to see which symlinks and targets were detected. See [Configuration](./configuration#symlinks-in-allowed-paths). + ## Node.js HTTP(S) and proxy env vars Node's built-in `http`/`https` modules ignore `HTTP_PROXY`/`HTTPS_PROXY`. With greywall's default TUN-based transparent proxying, this is not an issue — all traffic is routed through the proxy regardless of whether the application respects proxy environment variables. diff --git a/internal/config/config.go b/internal/config/config.go index 6448ef7..c62c13e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,6 +70,13 @@ type NetworkConfig struct { Rules []NetworkRule `json:"rules,omitempty"` // Network rules sent to greyproxy per session } +// Symlink scan modes for FilesystemConfig.SymlinkScan. +const ( + SymlinkScanShallow = "shallow" // scan only direct entries of allowed directories (default) + SymlinkScanDeep = "deep" // recursively walk allowed directories (capped) + SymlinkScanOff = "off" // no scanning inside allowed directories +) + // FilesystemConfig defines filesystem restrictions. type FilesystemConfig struct { DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead @@ -78,6 +85,7 @@ type FilesystemConfig struct { AllowWrite []string `json:"allowWrite"` DenyWrite []string `json:"denyWrite"` AllowGitConfig bool `json:"allowGitConfig,omitempty"` + SymlinkScan string `json:"symlinkScan,omitempty"` // "shallow" (default), "deep", or "off": scan allowed dirs for symlinks pointing outside them } // IsDefaultDenyRead returns whether deny-by-default read mode is enabled. @@ -86,6 +94,14 @@ func (f *FilesystemConfig) IsDefaultDenyRead() bool { return f.DefaultDenyRead == nil || *f.DefaultDenyRead } +// SymlinkScanMode returns the symlink scan mode, defaulting to shallow. +func (f *FilesystemConfig) SymlinkScanMode() string { + if f.SymlinkScan == "" { + return SymlinkScanShallow + } + return f.SymlinkScan +} + // CommandConfig defines command restrictions. type CommandConfig struct { Deny []string `json:"deny"` @@ -262,6 +278,12 @@ func (c *Config) Validate() error { if slices.Contains(c.Filesystem.DenyWrite, "") { return errors.New("filesystem.denyWrite contains empty path") } + switch c.Filesystem.SymlinkScan { + case "", SymlinkScanShallow, SymlinkScanDeep, SymlinkScanOff: + default: + return fmt.Errorf("invalid filesystem.symlinkScan %q: must be %q, %q, or %q", + c.Filesystem.SymlinkScan, SymlinkScanShallow, SymlinkScanDeep, SymlinkScanOff) + } if slices.Contains(c.Command.Deny, "") { return errors.New("command.deny contains empty command") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c43b7d8..b0e6d28 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -748,3 +748,18 @@ func TestValidateProxyURL(t *testing.T) { }) } } + +func TestValidateSymlinkScan(t *testing.T) { + for _, mode := range []string{"", SymlinkScanShallow, SymlinkScanDeep, SymlinkScanOff} { + cfg := Default() + cfg.Filesystem.SymlinkScan = mode + if err := cfg.Validate(); err != nil { + t.Errorf("symlinkScan %q must be valid: %v", mode, err) + } + } + cfg := Default() + cfg.Filesystem.SymlinkScan = "recursive" + if err := cfg.Validate(); err == nil { + t.Error("symlinkScan \"recursive\" must be rejected") + } +} diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go index ed9b597..4597dbc 100644 --- a/internal/sandbox/dangerous.go +++ b/internal/sandbox/dangerous.go @@ -28,6 +28,23 @@ var DangerousDirectories = []string{ ".claude/agents", } +// SensitiveUserDirs lists home-relative directories and files that hold +// credentials. The escaping-symlink scan (filesystem.symlinkScan) never +// auto-exposes targets under these paths; they require an explicit allowRead +// grant. +var SensitiveUserDirs = []string{ + ".ssh", + ".gnupg", + ".aws", + ".azure", + ".kube", + ".docker", + ".netrc", + ".git-credentials", + ".password-store", + ".config/gcloud", +} + // SensitiveProjectFiles lists files within the project directory that should be // denied for both read and write access. These commonly contain secrets. var SensitiveProjectFiles = []string{ diff --git a/internal/sandbox/glob.go b/internal/sandbox/glob.go new file mode 100644 index 0000000..63001db --- /dev/null +++ b/internal/sandbox/glob.go @@ -0,0 +1,161 @@ +package sandbox + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// ExpandGlobPatterns expands glob patterns to actual paths for sandbox rules +// (Landlock rules, bwrap binds, Seatbelt profiles). +// Optimized for Landlock's PATH_BENEATH semantics: +// - "dir/**" → returns just "dir" (Landlock covers descendants automatically) +// - "**/pattern" → scoped to cwd only, skips already-covered directories +// - "**/dir/**" → finds dirs in cwd, returns them (PATH_BENEATH covers contents) +func ExpandGlobPatterns(patterns []string) []string { + var expanded []string + seen := make(map[string]bool) + + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + + // First pass: collect directories covered by "dir/**" patterns + // These will be skipped when walking for "**/pattern" patterns + coveredDirs := make(map[string]bool) + for _, pattern := range patterns { + if !ContainsGlobChars(pattern) { + continue + } + pattern = NormalizePath(pattern) + if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") { + dir := strings.TrimSuffix(pattern, "/**") + if !strings.HasPrefix(dir, "/") { + dir = filepath.Join(cwd, dir) + } + // Store relative path for matching during walk + relDir, err := filepath.Rel(cwd, dir) + if err == nil { + coveredDirs[relDir] = true + } + } + } + + for _, pattern := range patterns { + if !ContainsGlobChars(pattern) { + // Not a glob, use as-is + normalized := NormalizePath(pattern) + if !seen[normalized] { + seen[normalized] = true + expanded = append(expanded, normalized) + } + continue + } + + // Normalize pattern + pattern = NormalizePath(pattern) + + // Case 1: "dir/**" - just return the dir (PATH_BENEATH handles descendants) + // This avoids walking the directory entirely + if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") { + dir := strings.TrimSuffix(pattern, "/**") + if !strings.HasPrefix(dir, "/") { + dir = filepath.Join(cwd, dir) + } + if !seen[dir] { + seen[dir] = true + expanded = append(expanded, dir) + } + continue + } + + // Case 2: "**/pattern" or "**/dir/**" - scope to cwd only + // Skip directories already covered by dir/** patterns + if strings.HasPrefix(pattern, "**/") { + // Extract what we're looking for after the **/ + suffix := strings.TrimPrefix(pattern, "**/") + + // If it ends with /**, we're looking for directories + isDir := strings.HasSuffix(suffix, "/**") + if isDir { + suffix = strings.TrimSuffix(suffix, "/**") + } + + // Walk cwd looking for matches, skipping covered directories + fsys := os.DirFS(cwd) + searchPattern := "**/" + suffix + + err := doublestar.GlobWalk(fsys, searchPattern, func(path string, d fs.DirEntry) error { + // Skip directories that are already covered by dir/** patterns + // Check each parent directory of the current path + pathParts := strings.Split(path, string(filepath.Separator)) + for i := 1; i <= len(pathParts); i++ { + parentPath := strings.Join(pathParts[:i], string(filepath.Separator)) + if coveredDirs[parentPath] { + if d.IsDir() { + return fs.SkipDir + } + return nil // Skip this file, it's under a covered dir + } + } + + absPath := filepath.Join(cwd, path) + if !seen[absPath] { + seen[absPath] = true + expanded = append(expanded, absPath) + } + return nil + }) + if err != nil { + continue + } + continue + } + + // Case 3: Other patterns with * but not ** - use standard glob scoped to cwd + if !strings.Contains(pattern, "**") { + var searchBase string + var searchPattern string + + if strings.HasPrefix(pattern, "/") { + // Absolute pattern - find the non-glob prefix + parts := strings.Split(pattern, "/") + var baseparts []string + for _, p := range parts { + if ContainsGlobChars(p) { + break + } + baseparts = append(baseparts, p) + } + searchBase = strings.Join(baseparts, "/") + if searchBase == "" { + searchBase = "/" + } + searchPattern = strings.TrimPrefix(pattern, searchBase+"/") + } else { + searchBase = cwd + searchPattern = pattern + } + + fsys := os.DirFS(searchBase) + matches, err := doublestar.Glob(fsys, searchPattern) + if err != nil { + continue + } + + for _, match := range matches { + absPath := filepath.Join(searchBase, match) + if !seen[absPath] { + seen[absPath] = true + expanded = append(expanded, absPath) + } + } + } + } + + return expanded +} diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index c97938a..55dff50 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -803,6 +803,84 @@ func getMandatoryDenyPaths(cwd string) []string { return paths } +// bindWithDirsArgs returns bwrap args that bind path at itself, creating +// intermediary --dir entries so the mount point exists under the tmpfs root. +func bindWithDirsArgs(path string, write bool) []string { + dirTarget := path + if !isDirectory(path) { + dirTarget = filepath.Dir(path) + } + var args []string + for _, dir := range intermediaryDirs("/", dirTarget) { + if !isSystemMountPoint(dir) { + args = append(args, "--dir", dir) + } + } + flag := "--ro-bind" + if write { + flag = "--bind" + } + return append(args, flag, path, path) +} + +// symlinkBindArgs returns bwrap args that expose symlink p inside the +// sandbox: the link itself is recreated with --symlink and its resolved +// target is bound at the target's real path, so access through the link path +// and through realpath() both work. When the link lives under cwd it is +// already visible through the cwd bind, so only the target is bound (bwrap +// before 0.6 errors on --symlink over an existing destination). boundTargets +// (optional) deduplicates target binds across calls. Returns nil when p is +// not a resolvable symlink. +func symlinkBindArgs(p, cwd string, write, debug bool, boundTargets map[string]bool) []string { + entry, ok := resolveSymlinkEntry(p) + if !ok { + if debug { + fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping unresolvable symlink: %s\n", p) + } + return nil + } + var args []string + if cwd == "" || !strings.HasPrefix(p, cwd+string(filepath.Separator)) { + for _, dir := range intermediaryDirs("/", filepath.Dir(p)) { + if !isSystemMountPoint(dir) { + args = append(args, "--dir", dir) + } + } + args = append(args, "--symlink", entry.LinkDest, p) + } + if boundTargets == nil || !boundTargets[entry.Target] { + if boundTargets != nil { + boundTargets[entry.Target] = true + } + args = append(args, bindWithDirsArgs(entry.Target, write)...) + } + if debug { + fmt.Fprintf(os.Stderr, "[greywall:linux] Recreated symlink %s -> %s\n", p, entry.Target) + } + return args +} + +// escapingSymlinkBindArgs scans an allowed directory for symlinks whose +// targets lie outside it (per filesystem.symlinkScan) and binds those targets +// read-only so the links resolve inside the sandbox. +func escapingSymlinkBindArgs(dir, mode string, debug bool, boundTargets map[string]bool) []string { + if mode == config.SymlinkScanOff { + return nil + } + var args []string + for _, entry := range scanEscapingSymlinks(dir, mode == config.SymlinkScanDeep, debug) { + if boundTargets[entry.Target] || !fileExists(entry.Target) { + continue + } + boundTargets[entry.Target] = true + args = append(args, bindWithDirsArgs(entry.Target, false)...) + if debug { + fmt.Fprintf(os.Stderr, "[greywall:linux] Binding symlink target %s (link %s in allowed dir %s)\n", entry.Target, entry.Link, dir) + } + } + return args +} + // buildDenyByDefaultMounts builds bwrap arguments for deny-by-default filesystem isolation. // Starts with --tmpfs / (empty root), then selectively mounts system paths read-only, // CWD read-write, and user tooling paths read-only. Sensitive files within CWD are masked. @@ -810,6 +888,14 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr var args []string home, _ := os.UserHomeDir() + symlinkScan := config.SymlinkScanShallow + if cfg != nil { + symlinkScan = cfg.Filesystem.SymlinkScanMode() + } + // Paths bound at their own location so far (binds and symlink targets), + // shared across the loops below to avoid duplicate mounts. + boundPaths := make(map[string]bool) + // Start with empty root args = append(args, "--tmpfs", "/") @@ -889,7 +975,13 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr if !strings.HasPrefix(p, home) { continue // Only user tooling paths need intermediary dirs } - if !fileExists(p) || !canMountOver(p) { + if !fileExists(p) { + continue + } + if isSymlink(p) { + // Symlinked tooling path (e.g. dotfiles-managed ~/.config): + // recreate the link and bind its target. + args = append(args, symlinkBindArgs(p, cwd, false, debug, boundPaths)...) continue } // Create intermediary dirs between root and this path @@ -900,6 +992,7 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr } } args = append(args, "--ro-bind", p, p) + boundPaths[p] = true } // Shell config files in home (read-only, literal files) @@ -907,66 +1000,95 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr homeIntermedaryAdded := boundDirs[home] for _, f := range shellConfigs { p := filepath.Join(home, f) - if fileExists(p) && canMountOver(p) { - if !homeIntermedaryAdded { - for _, dir := range intermediaryDirs("/", home) { - if !boundDirs[dir] && !isSystemMountPoint(dir) { - boundDirs[dir] = true - args = append(args, "--dir", dir) - } + if !fileExists(p) { + continue + } + if isSymlink(p) { + args = append(args, symlinkBindArgs(p, cwd, false, debug, boundPaths)...) + continue + } + if !homeIntermedaryAdded { + for _, dir := range intermediaryDirs("/", home) { + if !boundDirs[dir] && !isSystemMountPoint(dir) { + boundDirs[dir] = true + args = append(args, "--dir", dir) } - homeIntermedaryAdded = true } - args = append(args, "--ro-bind", p, p) + homeIntermedaryAdded = true } + args = append(args, "--ro-bind", p, p) + boundPaths[p] = true } // Home tool caches (read-only, for package managers/configs) homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"} for _, d := range homeCaches { p := filepath.Join(home, d) - if fileExists(p) && canMountOver(p) { - if !homeIntermedaryAdded { - for _, dir := range intermediaryDirs("/", home) { - if !boundDirs[dir] && !isSystemMountPoint(dir) { - boundDirs[dir] = true - args = append(args, "--dir", dir) - } + if !fileExists(p) { + continue + } + if isSymlink(p) { + args = append(args, symlinkBindArgs(p, cwd, false, debug, boundPaths)...) + continue + } + if !homeIntermedaryAdded { + for _, dir := range intermediaryDirs("/", home) { + if !boundDirs[dir] && !isSystemMountPoint(dir) { + boundDirs[dir] = true + args = append(args, "--dir", dir) } - homeIntermedaryAdded = true } - args = append(args, "--ro-bind", p, p) + homeIntermedaryAdded = true } + args = append(args, "--ro-bind", p, p) + boundPaths[p] = true } } // User-specified allowRead paths (read-only) if cfg != nil && cfg.Filesystem.AllowRead != nil { - boundPaths := make(map[string]bool) - expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead) for _, p := range expandedPaths { - if fileExists(p) && canMountOver(p) && - !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] { - boundPaths[p] = true - // Create intermediary dirs if needed. - // For files, only create dirs up to the parent to avoid - // creating a directory at the file's path. - dirTarget := p - if !isDirectory(p) { - dirTarget = filepath.Dir(p) - } - for _, dir := range intermediaryDirs("/", dirTarget) { - if !isSystemMountPoint(dir) { - args = append(args, "--dir", dir) - } + if !fileExists(p) || strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/proc/") || boundPaths[p] { + continue + } + boundPaths[p] = true + if isSymlink(p) { + // Glob matches can be symlinks: recreate the link and bind + // its target so the link resolves inside the sandbox. + args = append(args, symlinkBindArgs(p, cwd, false, debug, boundPaths)...) + continue + } + // Create intermediary dirs if needed. + // For files, only create dirs up to the parent to avoid + // creating a directory at the file's path. + dirTarget := p + if !isDirectory(p) { + dirTarget = filepath.Dir(p) + } + for _, dir := range intermediaryDirs("/", dirTarget) { + if !isSystemMountPoint(dir) { + args = append(args, "--dir", dir) } - args = append(args, "--ro-bind", p, p) + } + args = append(args, "--ro-bind", p, p) + if isDirectory(p) { + args = append(args, escapingSymlinkBindArgs(p, symlinkScan, debug, boundPaths)...) } } for _, p := range cfg.Filesystem.AllowRead { normalized := NormalizePath(p) - if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) && + if ContainsGlobChars(normalized) { + continue + } + // When the entry itself is a symlink, NormalizePath resolved it + // and the loop above bound the target. Recreate the link path + // too: programs open the entry's original path. + if expanded := ExpandPath(p); expanded != normalized && !boundPaths[expanded] && isSymlink(expanded) { + boundPaths[expanded] = true + args = append(args, symlinkBindArgs(expanded, cwd, false, debug, boundPaths)...) + } + if fileExists(normalized) && canMountOver(normalized) && !strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] { boundPaths[normalized] = true dirTarget := normalized @@ -979,6 +1101,9 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr } } args = append(args, "--ro-bind", normalized, normalized) + if isDirectory(normalized) { + args = append(args, escapingSymlinkBindArgs(normalized, symlinkScan, debug, boundPaths)...) + } } } } @@ -1047,13 +1172,28 @@ func writableBindArgs(cfg *config.Config) []string { normalized := NormalizePath(p) if !ContainsGlobChars(normalized) { writablePaths[normalized] = true + // When the entry is a symlink, NormalizePath resolved it. + // Track the link path too so it is recreated in the sandbox. + if expanded := ExpandPath(p); expanded != normalized && isSymlink(expanded) { + writablePaths[expanded] = true + } } } } var args []string + bound := make(map[string]bool) + cwd, _ := os.Getwd() for p := range writablePaths { - if fileExists(p) { + if !fileExists(p) { + continue + } + if isSymlink(p) { + args = append(args, symlinkBindArgs(p, cwd, true, false, bound)...) + continue + } + if !bound[p] { + bound[p] = true args = append(args, "--bind", p, p) } } @@ -1243,6 +1383,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge if cfg != nil && cfg.Filesystem.DenyRead != nil { expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead) for _, p := range expandedDenyRead { + // A glob match can be a symlink (canMountOver rejects those). + // Mask the resolved target instead, otherwise the denied + // content stays readable through the link. + if isSymlink(p) { + if resolved, err := filepath.EvalSymlinks(p); err == nil { + p = resolved + } + } if canMountOver(p) { if isDirectory(p) { bwrapArgs = append(bwrapArgs, "--tmpfs", p) diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index dfc5b89..29277fa 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -4,14 +4,12 @@ package sandbox import ( "fmt" - "io/fs" "os" "path/filepath" "strings" "unsafe" "github.com/GreyhavenHQ/greywall/internal/config" - "github.com/bmatcuk/doublestar/v4" "golang.org/x/sys/unix" ) @@ -499,153 +497,3 @@ func (l *LandlockRuleset) Close() error { } return nil } - -// ExpandGlobPatterns expands glob patterns to actual paths for Landlock rules. -// Optimized for Landlock's PATH_BENEATH semantics: -// - "dir/**" → returns just "dir" (Landlock covers descendants automatically) -// - "**/pattern" → scoped to cwd only, skips already-covered directories -// - "**/dir/**" → finds dirs in cwd, returns them (PATH_BENEATH covers contents) -func ExpandGlobPatterns(patterns []string) []string { - var expanded []string - seen := make(map[string]bool) - - cwd, err := os.Getwd() - if err != nil { - cwd = "." - } - - // First pass: collect directories covered by "dir/**" patterns - // These will be skipped when walking for "**/pattern" patterns - coveredDirs := make(map[string]bool) - for _, pattern := range patterns { - if !ContainsGlobChars(pattern) { - continue - } - pattern = NormalizePath(pattern) - if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") { - dir := strings.TrimSuffix(pattern, "/**") - if !strings.HasPrefix(dir, "/") { - dir = filepath.Join(cwd, dir) - } - // Store relative path for matching during walk - relDir, err := filepath.Rel(cwd, dir) - if err == nil { - coveredDirs[relDir] = true - } - } - } - - for _, pattern := range patterns { - if !ContainsGlobChars(pattern) { - // Not a glob, use as-is - normalized := NormalizePath(pattern) - if !seen[normalized] { - seen[normalized] = true - expanded = append(expanded, normalized) - } - continue - } - - // Normalize pattern - pattern = NormalizePath(pattern) - - // Case 1: "dir/**" - just return the dir (PATH_BENEATH handles descendants) - // This avoids walking the directory entirely - if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") { - dir := strings.TrimSuffix(pattern, "/**") - if !strings.HasPrefix(dir, "/") { - dir = filepath.Join(cwd, dir) - } - if !seen[dir] { - seen[dir] = true - expanded = append(expanded, dir) - } - continue - } - - // Case 2: "**/pattern" or "**/dir/**" - scope to cwd only - // Skip directories already covered by dir/** patterns - if strings.HasPrefix(pattern, "**/") { - // Extract what we're looking for after the **/ - suffix := strings.TrimPrefix(pattern, "**/") - - // If it ends with /**, we're looking for directories - isDir := strings.HasSuffix(suffix, "/**") - if isDir { - suffix = strings.TrimSuffix(suffix, "/**") - } - - // Walk cwd looking for matches, skipping covered directories - fsys := os.DirFS(cwd) - searchPattern := "**/" + suffix - - err := doublestar.GlobWalk(fsys, searchPattern, func(path string, d fs.DirEntry) error { - // Skip directories that are already covered by dir/** patterns - // Check each parent directory of the current path - pathParts := strings.Split(path, string(filepath.Separator)) - for i := 1; i <= len(pathParts); i++ { - parentPath := strings.Join(pathParts[:i], string(filepath.Separator)) - if coveredDirs[parentPath] { - if d.IsDir() { - return fs.SkipDir - } - return nil // Skip this file, it's under a covered dir - } - } - - absPath := filepath.Join(cwd, path) - if !seen[absPath] { - seen[absPath] = true - expanded = append(expanded, absPath) - } - return nil - }) - if err != nil { - continue - } - continue - } - - // Case 3: Other patterns with * but not ** - use standard glob scoped to cwd - if !strings.Contains(pattern, "**") { - var searchBase string - var searchPattern string - - if strings.HasPrefix(pattern, "/") { - // Absolute pattern - find the non-glob prefix - parts := strings.Split(pattern, "/") - var baseparts []string - for _, p := range parts { - if ContainsGlobChars(p) { - break - } - baseparts = append(baseparts, p) - } - searchBase = strings.Join(baseparts, "/") - if searchBase == "" { - searchBase = "/" - } - searchPattern = strings.TrimPrefix(pattern, searchBase+"/") - } else { - searchBase = cwd - searchPattern = pattern - } - - fsys := os.DirFS(searchBase) - matches, err := doublestar.Glob(fsys, searchPattern) - if err != nil { - continue - } - - for _, match := range matches { - absPath := filepath.Join(searchBase, match) - if !seen[absPath] { - seen[absPath] = true - expanded = append(expanded, absPath) - } - } - } - } - - return expanded -} diff --git a/internal/sandbox/linux_landlock_stub.go b/internal/sandbox/linux_landlock_stub.go index 2851036..f07bd05 100644 --- a/internal/sandbox/linux_landlock_stub.go +++ b/internal/sandbox/linux_landlock_stub.go @@ -35,11 +35,6 @@ func (l *LandlockRuleset) Apply() error { return nil } // Close is a no-op on non-Linux platforms. func (l *LandlockRuleset) Close() error { return nil } -// ExpandGlobPatterns returns the input on non-Linux platforms. -func ExpandGlobPatterns(patterns []string) []string { - return patterns -} - // GenerateLandlockSetupScript returns empty on non-Linux platforms. func GenerateLandlockSetupScript(allowWrite, denyWrite, denyRead []string, debug bool) string { return "" diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index 75874d3..30243c4 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -52,6 +52,7 @@ type MacOSSandboxParams struct { AllowGitConfig bool Shell string RewrittenEnvFiles map[string]string // original path -> rewritten temp path + SymlinkScan string // filesystem.symlinkScan mode ("shallow", "deep", "off") } // GlobToRegex converts a glob pattern to a regex for macOS sandbox profiles. @@ -153,8 +154,40 @@ func getTmpdirParent() []string { return []string{parent} } +// readRuleForPath returns a Seatbelt read-allow rule for a resolved path: +// subpath for directories, literal for files. +func readRuleForPath(p string) []string { + kind := "literal" + if info, err := os.Stat(p); err == nil && info.IsDir() { + kind = "subpath" + } + return []string{ + "(allow file-read-data", + fmt.Sprintf(" (%s %s))", kind, escapePath(p)), + } +} + +// escapingSymlinkReadRules returns read-allow rules for symlinks under an +// allowed directory whose targets lie outside it (per filesystem.symlinkScan). +// Seatbelt evaluates rules against resolved paths, so without these rules a +// symlink pointing outside the allowed directory is denied. +func escapingSymlinkReadRules(dir, mode string) []string { + if mode == config.SymlinkScanOff { + return nil + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil + } + var rules []string + for _, entry := range scanEscapingSymlinks(dir, mode == config.SymlinkScanDeep, false) { + rules = append(rules, readRuleForPath(entry.Target)...) + } + return rules +} + // generateReadRules generates filesystem read rules for the sandbox profile. -func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, rewrittenEnvFiles map[string]string, logTag string) []string { +func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, rewrittenEnvFiles map[string]string, logTag, symlinkScan string) []string { var rules []string if defaultDenyRead { @@ -204,6 +237,14 @@ func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths [ rules, fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)), ) + // Seatbelt matches resolved paths: a symlinked config (e.g. + // dotfiles-managed) needs a rule for its target too. + if resolved, err := filepath.EvalSymlinks(p); err == nil && resolved != p { + rules = append( + rules, + fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(resolved)), + ) + } } // Home tool caches (subpath access for package managers/configs) @@ -215,6 +256,13 @@ func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths [ "(allow file-read-data", fmt.Sprintf(" (subpath %s))", escapePath(p)), ) + if resolved, err := filepath.EvalSymlinks(p); err == nil && resolved != p { + rules = append( + rules, + "(allow file-read-data", + fmt.Sprintf(" (subpath %s))", escapePath(resolved)), + ) + } } } @@ -229,12 +277,23 @@ func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths [ "(allow file-read-data", fmt.Sprintf(" (regex %s))", escapePath(regex)), ) + // The regex matches the literal path, but Seatbelt evaluates + // resolved paths: cover targets of matches that are symlinks, + // and symlinks escaping matched directories. + for _, m := range ExpandGlobPatterns([]string{pathPattern}) { + if entry, ok := resolveSymlinkEntry(m); ok { + rules = append(rules, readRuleForPath(entry.Target)...) + } else { + rules = append(rules, escapingSymlinkReadRules(m, symlinkScan)...) + } + } } else { rules = append( rules, "(allow file-read-data", fmt.Sprintf(" (subpath %s))", escapePath(normalized)), ) + rules = append(rules, escapingSymlinkReadRules(normalized, symlinkScan)...) } } @@ -676,7 +735,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { // Read rules profile.WriteString("; File read\n") - for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, params.RewrittenEnvFiles, logTag) { + for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, params.RewrittenEnvFiles, logTag, params.SymlinkScan) { profile.WriteString(rule + "\n") } profile.WriteString("\n") @@ -776,6 +835,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, re AllowPty: cfg.AllowPty, AllowGitConfig: cfg.Filesystem.AllowGitConfig, RewrittenEnvFiles: rewrittenEnvFiles, + SymlinkScan: cfg.Filesystem.SymlinkScanMode(), } if debug && len(exposedPorts) > 0 { diff --git a/internal/sandbox/mounts_linux_test.go b/internal/sandbox/mounts_linux_test.go index 135e56e..b5fd910 100644 --- a/internal/sandbox/mounts_linux_test.go +++ b/internal/sandbox/mounts_linux_test.go @@ -81,3 +81,119 @@ func TestLinux_SessionAllowPaths(t *testing.T) { } } } + +// TestLinux_SymlinkedAllowRead covers issue #91: allowRead entries that are +// symlinks must work inside the sandbox. The link is recreated (--symlink) +// and its resolved target is bound at the target's real path, so opening the +// link path and the realpath both work. +func TestLinux_SymlinkedAllowRead(t *testing.T) { + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(tmp, "real.json") + if err := os.WriteFile(target, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "link.json") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + cfg := config.Default() + cfg.Filesystem.AllowRead = []string{link} + + args := buildDenyByDefaultMounts(cfg, t.TempDir(), nil, nil, false) + if !hasBindTriple(args, "--symlink", target, link) { + t.Errorf("symlink %s not recreated (--symlink %s %s)\nargs: %v", link, target, link, args) + } + if !hasBindTriple(args, "--ro-bind", target, target) { + t.Errorf("symlink target %s not bound readable\nargs: %v", target, args) + } +} + +// TestLinux_EscapingSymlinkInAllowedDir verifies that symlinks inside an +// allowed directory pointing outside it get their targets bound, per the +// filesystem.symlinkScan mode. +func TestLinux_EscapingSymlinkInAllowedDir(t *testing.T) { + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + outside := filepath.Join(tmp, "outside.txt") + if err := os.WriteFile(outside, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(tmp, "allowed") + sub := filepath.Join(dir, "sub") + if err := os.MkdirAll(sub, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "top-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(sub, "deep-link")); err != nil { + t.Fatal(err) + } + + cwd := t.TempDir() + cfg := config.Default() + cfg.Filesystem.AllowRead = []string{dir} + + // Default (shallow): direct entries scanned, target bound. + args := buildDenyByDefaultMounts(cfg, cwd, nil, nil, false) + if !hasBindTriple(args, "--ro-bind", outside, outside) { + t.Errorf("shallow scan: escaping symlink target %s not bound\nargs: %v", outside, args) + } + + // Off: no scan, target not bound. + cfg.Filesystem.SymlinkScan = config.SymlinkScanOff + args = buildDenyByDefaultMounts(cfg, cwd, nil, nil, false) + if hasBindTriple(args, "--ro-bind", outside, outside) { + t.Errorf("scan off: escaping symlink target %s must not be bound\nargs: %v", outside, args) + } + + // Deep: nested links found too. Remove the top-level link so only the + // nested one can produce the bind. + if err := os.Remove(filepath.Join(dir, "top-link")); err != nil { + t.Fatal(err) + } + cfg.Filesystem.SymlinkScan = config.SymlinkScanShallow + args = buildDenyByDefaultMounts(cfg, cwd, nil, nil, false) + if hasBindTriple(args, "--ro-bind", outside, outside) { + t.Errorf("shallow scan: nested symlink target %s must not be bound\nargs: %v", outside, args) + } + cfg.Filesystem.SymlinkScan = config.SymlinkScanDeep + args = buildDenyByDefaultMounts(cfg, cwd, nil, nil, false) + if !hasBindTriple(args, "--ro-bind", outside, outside) { + t.Errorf("deep scan: nested symlink target %s not bound\nargs: %v", outside, args) + } +} + +// TestLinux_SymlinkedAllowWrite verifies writableBindArgs recreates symlinked +// allowWrite entries and binds their targets writable. +func TestLinux_SymlinkedAllowWrite(t *testing.T) { + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(tmp, "data") + if err := os.MkdirAll(target, 0o750); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "data-link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + cfg := config.Default() + cfg.Filesystem.AllowWrite = []string{link} + + args := writableBindArgs(cfg) + if !hasBindTriple(args, "--symlink", target, link) { + t.Errorf("symlink %s not recreated\nargs: %v", link, args) + } + if !hasBindTriple(args, "--bind", target, target) { + t.Errorf("symlink target %s not bound writable\nargs: %v", target, args) + } +} diff --git a/internal/sandbox/symlinks.go b/internal/sandbox/symlinks.go new file mode 100644 index 0000000..77a46ac --- /dev/null +++ b/internal/sandbox/symlinks.go @@ -0,0 +1,135 @@ +package sandbox + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// maxSymlinkScanEntries caps how many directory entries a deep symlink scan +// visits, so a huge allowed directory cannot stall sandbox startup. +const maxSymlinkScanEntries = 10000 + +// SymlinkEntry describes a symbolic link in an allowed path whose target the +// sandbox must also expose for the link to resolve. +type SymlinkEntry struct { + Link string // absolute path of the symlink itself + LinkDest string // raw link content from os.Readlink (may be relative) + Target string // fully resolved absolute target +} + +// resolveSymlinkEntry returns the SymlinkEntry for p when p is a symlink with +// a resolvable target. Returns ok=false for regular paths, dangling links, +// and unreadable links. +func resolveSymlinkEntry(p string) (SymlinkEntry, bool) { + info, err := os.Lstat(p) // Lstat doesn't follow symlinks + if err != nil || info.Mode()&os.ModeSymlink == 0 { + return SymlinkEntry{}, false + } + linkDest, err := os.Readlink(p) + if err != nil { + return SymlinkEntry{}, false + } + target, err := filepath.EvalSymlinks(p) + if err != nil { + return SymlinkEntry{}, false + } + return SymlinkEntry{Link: p, LinkDest: linkDest, Target: target}, true +} + +// sensitiveScanTargets lists paths the escaping-symlink scan must never +// auto-expose. Allowed directories can be writable inside the sandbox, so a +// sandboxed process could plant a symlink there and have the next session +// bind its target. Explicit allowRead grants are unaffected. +func sensitiveScanTargets() []string { + var paths []string + if home, err := os.UserHomeDir(); err == nil && home != "" { + for _, d := range SensitiveUserDirs { + paths = append(paths, filepath.Join(home, d)) + } + } + return append(paths, GetSensitiveSystemPaths()...) +} + +// isSensitiveScanTarget returns true when target must not be auto-exposed by +// the escaping-symlink scan. +func isSensitiveScanTarget(target string) bool { + for _, p := range sensitiveScanTargets() { + if target == p || strings.HasPrefix(target, p+string(filepath.Separator)) { + return true + } + } + // Credential-bearing project files (.env and variants). + base := filepath.Base(target) + for _, f := range SensitiveProjectFiles { + if base == f { + return true + } + } + return strings.HasPrefix(base, ".env.") +} + +// scanEscapingSymlinks finds symlinks under root whose resolved target lies +// outside root. Those targets are not reachable inside the sandbox unless +// they are exposed separately. With deep=false only root's direct entries are +// examined (a single directory read). With deep=true the whole tree is +// walked, bounded by maxSymlinkScanEntries. +func scanEscapingSymlinks(root string, deep, debug bool) []SymlinkEntry { + root = filepath.Clean(root) + prefix := root + string(filepath.Separator) + var entries []SymlinkEntry + + collect := func(p string) { + entry, ok := resolveSymlinkEntry(p) + if !ok { + if debug { + fmt.Fprintf(os.Stderr, "[greywall:symlink] Skipping unresolvable symlink: %s\n", p) + } + return + } + if entry.Target == root || strings.HasPrefix(entry.Target, prefix) { + return // target stays inside the allowed directory + } + if isSensitiveScanTarget(entry.Target) { + if debug { + fmt.Fprintf(os.Stderr, "[greywall:symlink] Not exposing sensitive symlink target: %s -> %s\n", p, entry.Target) + } + return + } + entries = append(entries, entry) + } + + if !deep { + dirEntries, err := os.ReadDir(root) + if err != nil { + return nil + } + for _, e := range dirEntries { + if e.Type()&fs.ModeSymlink != 0 { + collect(filepath.Join(root, e.Name())) + } + } + return entries + } + + visited := 0 + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // best-effort scan: skip unreadable entries + } + visited++ + if visited > maxSymlinkScanEntries { + if debug { + fmt.Fprintf(os.Stderr, "[greywall:symlink] Deep scan of %s stopped after %d entries\n", root, maxSymlinkScanEntries) + } + return filepath.SkipAll + } + if d.Type()&fs.ModeSymlink != 0 { + collect(p) + } + return nil + }) + return entries +} diff --git a/internal/sandbox/symlinks_test.go b/internal/sandbox/symlinks_test.go new file mode 100644 index 0000000..76d2734 --- /dev/null +++ b/internal/sandbox/symlinks_test.go @@ -0,0 +1,209 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// mustResolve resolves symlinks in a path (t.TempDir may itself sit behind a +// symlink, e.g. /tmp or /var on macOS). +func mustResolve(t *testing.T, p string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func TestResolveSymlinkEntry(t *testing.T) { + tmp := mustResolve(t, t.TempDir()) + target := filepath.Join(tmp, "target.txt") + if err := os.WriteFile(target, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "link.txt") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + dangling := filepath.Join(tmp, "dangling") + if err := os.Symlink(filepath.Join(tmp, "missing"), dangling); err != nil { + t.Fatal(err) + } + + if _, ok := resolveSymlinkEntry(target); ok { + t.Error("regular file must not resolve as symlink entry") + } + if _, ok := resolveSymlinkEntry(dangling); ok { + t.Error("dangling symlink must not resolve") + } + entry, ok := resolveSymlinkEntry(link) + if !ok { + t.Fatal("symlink did not resolve") + } + if entry.Link != link || entry.LinkDest != target || entry.Target != target { + t.Errorf("unexpected entry: %+v", entry) + } +} + +func TestScanEscapingSymlinks(t *testing.T) { + tmp := mustResolve(t, t.TempDir()) + outside := filepath.Join(tmp, "outside.txt") + if err := os.WriteFile(outside, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tmp, "allowed") + sub := filepath.Join(dir, "sub") + if err := os.MkdirAll(sub, 0o750); err != nil { + t.Fatal(err) + } + internal := filepath.Join(dir, "internal.txt") + if err := os.WriteFile(internal, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + // Internal link (target inside dir), escaping link at top level, + // escaping link in a subdirectory, dangling link. + if err := os.Symlink(internal, filepath.Join(dir, "internal-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "escape-top")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(sub, "escape-deep")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(tmp, "missing"), filepath.Join(dir, "dangling")); err != nil { + t.Fatal(err) + } + + targets := func(entries []SymlinkEntry) []string { + var out []string + for _, e := range entries { + out = append(out, e.Link+"->"+e.Target) + } + return out + } + + shallow := scanEscapingSymlinks(dir, false, false) + if len(shallow) != 1 { + t.Fatalf("shallow scan: want 1 escaping link, got %v", targets(shallow)) + } + if shallow[0].Link != filepath.Join(dir, "escape-top") || shallow[0].Target != outside { + t.Errorf("shallow scan: unexpected entry %+v", shallow[0]) + } + + deep := scanEscapingSymlinks(dir, true, false) + if len(deep) != 2 { + t.Fatalf("deep scan: want 2 escaping links, got %v", targets(deep)) + } + found := false + for _, e := range deep { + if e.Link == filepath.Join(sub, "escape-deep") && e.Target == outside { + found = true + } + } + if !found { + t.Errorf("deep scan: missing nested escaping link, got %v", targets(deep)) + } +} + +func TestExpandPathKeepsSymlinks(t *testing.T) { + tmp := mustResolve(t, t.TempDir()) + target := filepath.Join(tmp, "target") + if err := os.MkdirAll(target, 0o750); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if got := ExpandPath(link); got != link { + t.Errorf("ExpandPath(%q) = %q, must not resolve symlinks", link, got) + } + if got := NormalizePath(link); got != target { + t.Errorf("NormalizePath(%q) = %q, want resolved %q", link, got, target) + } + + home, _ := os.UserHomeDir() + if got := ExpandPath("~"); got != home { + t.Errorf("ExpandPath(~) = %q, want %q", got, home) + } +} + +func TestEscapingSymlinkReadRules(t *testing.T) { + tmp := mustResolve(t, t.TempDir()) + outside := filepath.Join(tmp, "secret.txt") + if err := os.WriteFile(outside, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(tmp, "allowed") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + + rules := strings.Join(escapingSymlinkReadRules(dir, "shallow"), "\n") + want := "(literal " + escapePath(outside) + "))" + if !strings.Contains(rules, want) { + t.Errorf("shallow rules missing target literal %q:\n%s", want, rules) + } + + if rules := escapingSymlinkReadRules(dir, "off"); rules != nil { + t.Errorf("mode off must produce no rules, got %v", rules) + } +} + +func TestGenerateReadRulesSymlinkedAllowPath(t *testing.T) { + tmp := mustResolve(t, t.TempDir()) + targetDir := filepath.Join(tmp, "real-config") + if err := os.MkdirAll(targetDir, 0o750); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "linked-config") + if err := os.Symlink(targetDir, link); err != nil { + t.Fatal(err) + } + + rules := strings.Join(generateReadRules(true, tmp, []string{link}, nil, nil, "TAG", "shallow"), "\n") + want := "(subpath " + escapePath(targetDir) + "))" + if !strings.Contains(rules, want) { + t.Errorf("read rules missing resolved subpath %q:\n%s", want, rules) + } +} + +func TestScanSkipsSensitiveTargets(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + t.Skip("no home directory") + } + tmp := mustResolve(t, t.TempDir()) + dir := filepath.Join(tmp, "allowed") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + sshDir := filepath.Join(home, ".ssh") + if _, err := os.Stat(sshDir); err != nil { + t.Skipf("no %s on this system", sshDir) + } + if err := os.Symlink(sshDir, filepath.Join(dir, "ssh-link")); err != nil { + t.Fatal(err) + } + envTarget := filepath.Join(tmp, ".env") + if err := os.WriteFile(envTarget, []byte("SECRET=1"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(envTarget, filepath.Join(dir, "env-link")); err != nil { + t.Fatal(err) + } + + for _, e := range scanEscapingSymlinks(dir, true, false) { + t.Errorf("sensitive target must not be exposed: %s -> %s", e.Link, e.Target) + } +} diff --git a/internal/sandbox/utils.go b/internal/sandbox/utils.go index a7625f1..1a57e9a 100644 --- a/internal/sandbox/utils.go +++ b/internal/sandbox/utils.go @@ -18,26 +18,34 @@ func RemoveTrailingGlobSuffix(pattern string) string { return strings.TrimSuffix(pattern, "/**") } -// NormalizePath normalizes a path for sandbox configuration. -// Handles tilde expansion and relative paths. -func NormalizePath(pathPattern string) string { +// ExpandPath expands ~ and relative paths to an absolute path without +// resolving symlinks. Use NormalizePath when symlink resolution is wanted. +func ExpandPath(pathPattern string) string { home, _ := os.UserHomeDir() cwd, _ := os.Getwd() - normalized := pathPattern + expanded := pathPattern // Expand ~ and relative paths switch { case pathPattern == "~": - normalized = home + expanded = home case strings.HasPrefix(pathPattern, "~/"): - normalized = filepath.Join(home, pathPattern[2:]) + expanded = filepath.Join(home, pathPattern[2:]) case strings.HasPrefix(pathPattern, "./"), strings.HasPrefix(pathPattern, "../"): - normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) + expanded, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) case !filepath.IsAbs(pathPattern) && !ContainsGlobChars(pathPattern): - normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) + expanded, _ = filepath.Abs(filepath.Join(cwd, pathPattern)) } + return expanded +} + +// NormalizePath normalizes a path for sandbox configuration. +// Handles tilde expansion, relative paths, and symlink resolution. +func NormalizePath(pathPattern string) string { + normalized := ExpandPath(pathPattern) + // For non-glob patterns, try to resolve symlinks if !ContainsGlobChars(normalized) { if resolved, err := filepath.EvalSymlinks(normalized); err == nil {