Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
17 changes: 17 additions & 0 deletions internal/sandbox/dangerous.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
161 changes: 161 additions & 0 deletions internal/sandbox/glob.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading