5 Commits

Author SHA1 Message Date
a04f5feee2 fix: prevent learning mode from collapsing read paths to $HOME
Files directly under ~ (e.g., ~/.gitignore, ~/.npmrc) were collapsed
to the home directory, defeating sandboxing. Now keeps exact file paths
when the parent directory would be $HOME.
2026-02-13 11:38:51 -06:00
c95fca830b docs: add Linux deny-by-default lessons to experience.md
Document three issues encountered during --tmpfs / isolation:
symlinked system dirs on merged-usr distros, Landlock denying
reads on bind-mounted /dev/null, and mandatory deny paths
overriding sensitive file masks.
2026-02-12 20:16:37 -06:00
5affaf77a5 feat: deny-by-default filesystem isolation
Flip the sandbox from allow-by-default reads (--ro-bind / /) to
deny-by-default (--tmpfs / with selective mounts). This makes the
sandbox safer by default — only system paths, CWD, and explicitly
allowed paths are accessible.

- Config: DefaultDenyRead is now *bool (nil = true, deny-by-default)
  with IsDefaultDenyRead() helper; opt out via "defaultDenyRead": false
- Linux: new buildDenyByDefaultMounts() using --tmpfs / + selective
  --ro-bind for system paths, --symlink for merged-usr distros (Arch),
  --bind for CWD, and --ro-bind for user tooling/shell configs/caches
- macOS: generateReadRules() adds CWD subpath, ancestor traversal,
  home shell configs/caches; generateWriteRules() auto-allows CWD
- Landlock: deny-by-default mode allows only specific user tooling
  paths instead of blanket home directory read access
- Sensitive .env files masked within CWD via empty-file overlay on
  Linux and deny rules on macOS
- Learning templates now include allowRead and .env deny patterns
2026-02-12 20:15:40 -06:00
b55b3364af feat: add dependency status to --version and document AppArmor userns fix
Some checks failed
Build and test / Build (push) Successful in 11s
Build and test / Lint (push) Failing after 1m24s
Build and test / Test (Linux) (push) Failing after 40s
Build and test / Test (macOS) (push) Has been cancelled
Show installed dependencies, security features, and transparent proxy
availability when running --version. Detect AppArmor
unprivileged_userns restriction on Ubuntu 24.04+ and suggest the fix.
Document the RTM_NEWADDR issue in experience.md.
2026-02-11 19:31:24 -06:00
70d0685c97 fix: use UDP instead of TCP for DNS bridge to host DNS server
The DnsBridge socat relay was forwarding queries via TCP, but the
GreyHaven DNS service (gost) only listens on UDP, causing DNS
resolution failures ("Could not resolve host") inside the sandbox.
2026-02-11 19:30:56 -06:00
14 changed files with 738 additions and 80 deletions

View File

@@ -125,6 +125,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Built: %s\n", buildTime)
fmt.Printf(" Commit: %s\n", gitCommit)
sandbox.PrintDependencyStatus()
return nil
}

View File

@@ -67,3 +67,55 @@ Lessons learned and issues encountered during development.
**Problem:** gost's SOCKS5 server always selects authentication method 0x02 (username/password), even when no real credentials are needed. Clients that only offer method 0x00 (no auth) get rejected.
**Fix:** Always include credentials in the proxy URL (e.g., `proxy:proxy@`). In tun2socks proxy URL construction, include `userinfo` so tun2socks offers both auth methods during SOCKS5 negotiation.
---
## Network namespaces fail on Ubuntu 24.04 (`RTM_NEWADDR: Operation not permitted`)
**Problem:** On Ubuntu 24.04 (tested in a KVM guest with bridged virtio/virbr0), `--version` reports `bwrap(no-netns)` and transparent proxy is unavailable. `kernel.unprivileged_userns_clone=1` is set, bwrap and socat are installed, but `bwrap --unshare-net` fails with:
```
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
```
**Cause:** Ubuntu 24.04 introduced `kernel.apparmor_restrict_unprivileged_userns` (default: 1). This strips capabilities like `CAP_NET_ADMIN` from processes inside unprivileged user namespaces, even without a bwrap-specific AppArmor profile. Bubblewrap creates the network namespace successfully but cannot configure the loopback interface (adding 127.0.0.1 via netlink RTM_NEWADDR requires `CAP_NET_ADMIN`). Not a hypervisor issue — happens on bare metal Ubuntu 24.04 too.
**Diagnosis:**
```bash
sysctl kernel.apparmor_restrict_unprivileged_userns # likely returns 1
bwrap --unshare-net --ro-bind / / -- /bin/true # reproduces the error
```
**Fix:** Disable the restriction (requires root on the guest):
```bash
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Persist across reboots:
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf
```
**Alternative:** Accept the limitation — greywall still works for filesystem sandboxing, seccomp, and Landlock. Network access is blocked outright rather than redirected through a proxy.
---
## Linux: symlinked system dirs invisible after `--tmpfs /`
**Problem:** On merged-usr distros (Arch, Fedora, modern Ubuntu), `/bin`, `/sbin`, `/lib`, `/lib64` are symlinks (e.g., `/bin -> usr/bin`). When switching from `--ro-bind / /` to `--tmpfs /` for deny-by-default isolation, these symlinks don't exist in the empty root. The `canMountOver()` helper explicitly rejects symlinks, so `--ro-bind /bin /bin` was silently skipped. Result: `execvp /usr/bin/bash: No such file or directory` — bash exists at `/usr/bin/bash` but the dynamic linker at `/lib64/ld-linux-x86-64.so.2` can't be found because `/lib64` is missing.
**Diagnosis:** The error message is misleading. `execvp` reports "No such file or directory" both when the binary is missing and when the ELF interpreter (dynamic linker) is missing. The actual binary `/usr/bin/bash` existed via the `/usr` bind-mount, but the symlink `/lib64 -> usr/lib` was gone.
**Fix:** Check each system path with `isSymlink()` before mounting. Symlinks get `--symlink <target> <path>` (bwrap recreates the symlink inside the sandbox); real directories get `--ro-bind`. On Arch: `--symlink usr/bin /bin`, `--symlink usr/bin /sbin`, `--symlink usr/lib /lib`, `--symlink usr/lib /lib64`.
---
## Linux: Landlock denies reads on bind-mounted /dev/null
**Problem:** To mask `.env` files inside CWD, the initial approach used `--ro-bind /dev/null <cwd>/.env`. Inside the sandbox, `.env` appeared as a character device (bind mounts preserve file type). Landlock's `LANDLOCK_ACCESS_FS_READ_FILE` right only covers regular files, not character devices. Result: `cat .env` returned "Permission denied" instead of empty content.
**Fix:** Use an empty regular file (`/tmp/greywall/empty`, 0 bytes, mode 0444) as the mask source instead of `/dev/null`. Landlock sees a regular file and allows the read. The file is created once in a fixed location under the greywall temp dir.
---
## Linux: mandatory deny paths override sensitive file masks
**Problem:** In deny-by-default mode, `buildDenyByDefaultMounts()` correctly masked `.env` with `--ro-bind /tmp/greywall/empty <cwd>/.env`. But later in `WrapCommandLinuxWithOptions()`, the mandatory deny paths section called `getMandatoryDenyPaths()` which included `.env` files (added for write protection). It then applied `--ro-bind <cwd>/.env <cwd>/.env`, binding the real file over the empty mask. bwrap applies mounts in order, so the later ro-bind undid the masking.
**Fix:** Track paths already masked by `buildDenyByDefaultMounts()` in a set. Skip those paths in the mandatory deny section to preserve the empty-file overlay.

View File

@@ -36,7 +36,7 @@ type NetworkConfig struct {
// FilesystemConfig defines filesystem restrictions.
type FilesystemConfig struct {
DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead
DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
DenyRead []string `json:"denyRead"`
AllowWrite []string `json:"allowWrite"`
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// IsDefaultDenyRead returns whether deny-by-default read mode is enabled.
// Defaults to true when not explicitly set (nil).
func (f *FilesystemConfig) IsDefaultDenyRead() bool {
return f.DefaultDenyRead == nil || *f.DefaultDenyRead
}
// CommandConfig defines command restrictions.
type CommandConfig struct {
Deny []string `json:"deny"`
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
},
Filesystem: FilesystemConfig{
// Boolean fields: true if either enables it
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
// Append slices
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),

View File

@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: true,
DefaultDenyRead: boolPtr(true),
AllowRead: []string{"/home/user/project"},
},
}
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
}
result := Merge(base, override)
if !result.Filesystem.DefaultDenyRead {
t.Error("expected DefaultDenyRead to be true (from base)")
if !result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be true (from base)")
}
if len(result.Filesystem.AllowRead) != 2 {
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
}
})
t.Run("defaultDenyRead nil defaults to true", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{},
}
result := Merge(base, nil)
if !result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be true when nil (deny-by-default)")
}
})
t.Run("defaultDenyRead explicit false overrides", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: boolPtr(true),
},
}
override := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: boolPtr(false),
},
}
result := Merge(base, override)
if result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be false (override explicit false)")
}
})
}
func boolPtr(b bool) *bool {

View File

@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
".claude/agents",
}
// SensitiveProjectFiles lists files within the project directory that should be
// denied for both read and write access. These commonly contain secrets.
var SensitiveProjectFiles = []string{
".env",
".env.local",
".env.development",
".env.production",
".env.staging",
".env.test",
}
// GetSensitiveProjectPaths returns concrete paths for sensitive files within the
// given directory. Only returns paths for files that actually exist.
func GetSensitiveProjectPaths(cwd string) []string {
var paths []string
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
if _, err := os.Stat(p); err == nil {
paths = append(paths, p)
}
}
return paths
}
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
func GetDefaultWritePaths() []string {
home, _ := os.UserHomeDir()

View File

@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
// ============================================================================
// testConfig creates a test configuration with sensible defaults.
// Uses legacy mode (defaultDenyRead=false) for predictable testing of
// existing integration tests. Use testConfigDenyByDefault() for tests
// that specifically test deny-by-default behavior.
func testConfig() *config.Config {
return &config.Config{
Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
},
Command: config.CommandConfig{
Deny: []string{},

View File

@@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
allowWrite = append(allowWrite, toTildePath(p, home))
}
// Filter read paths: remove system defaults, CWD subtree, and sensitive paths
cwd, _ := os.Getwd()
var filteredReads []string
defaultReadable := GetDefaultReadablePaths()
for _, p := range result.ReadPaths {
// Skip system defaults
isDefault := false
for _, dp := range defaultReadable {
if p == dp || strings.HasPrefix(p, dp+"/") {
isDefault = true
break
}
}
if isDefault {
continue
}
// Skip CWD subtree (auto-included)
if cwd != "" && (p == cwd || strings.HasPrefix(p, cwd+"/")) {
continue
}
// Skip sensitive paths
if isSensitivePath(p, home) {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Skipping sensitive read path: %s\n", p)
}
continue
}
filteredReads = append(filteredReads, p)
}
// Collapse read paths and convert to tilde-relative
collapsedReads := CollapsePaths(filteredReads)
var allowRead []string
for _, p := range collapsedReads {
allowRead = append(allowRead, toTildePath(p, home))
}
// Convert read paths to tilde-relative for display
var readDisplay []string
for _, p := range result.ReadPaths {
@@ -103,6 +140,13 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
}
}
if len(allowRead) > 0 {
fmt.Fprintf(os.Stderr, "[greywall] Additional read paths (beyond system + CWD):\n")
for _, p := range allowRead {
fmt.Fprintf(os.Stderr, "[greywall] %s\n", p)
}
}
if len(allowWrite) > 1 { // >1 because "." is always included
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
for _, p := range allowWrite {
@@ -118,7 +162,7 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
fmt.Fprintf(os.Stderr, "\n")
// Build template
template := buildTemplate(cmdName, allowWrite)
template := buildTemplate(cmdName, allowRead, allowWrite)
// Save template
templatePath := LearnedTemplatePath(cmdName)
@@ -176,9 +220,15 @@ func CollapsePaths(paths []string) []string {
}
}
// For standalone paths, use their parent directory
// For standalone paths, use their parent directory — but never collapse to $HOME
for _, p := range standalone {
result = append(result, filepath.Dir(p))
parent := filepath.Dir(p)
if parent == home {
// Keep exact file path to avoid opening entire home directory
result = append(result, p)
} else {
result = append(result, parent)
}
}
// Sort and deduplicate (remove sub-paths of other paths)
@@ -345,9 +395,18 @@ func deduplicateSubPaths(paths []string) []string {
return result
}
// getSensitiveProjectDenyPatterns returns denyRead entries for sensitive project files.
func getSensitiveProjectDenyPatterns() []string {
return []string{
".env",
".env.*",
}
}
// buildTemplate generates the JSONC template content for a learned config.
func buildTemplate(cmdName string, allowWrite []string) string {
func buildTemplate(cmdName string, allowRead, allowWrite []string) string {
type fsConfig struct {
AllowRead []string `json:"allowRead,omitempty"`
AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"`
DenyRead []string `json:"denyRead"`
@@ -356,11 +415,15 @@ func buildTemplate(cmdName string, allowWrite []string) string {
Filesystem fsConfig `json:"filesystem"`
}
// Combine sensitive read patterns with .env project patterns
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
cfg := templateConfig{
Filesystem: fsConfig{
AllowRead: allowRead,
AllowWrite: allowWrite,
DenyWrite: getDangerousFilePatterns(),
DenyRead: getSensitiveReadPatterns(),
DenyRead: denyRead,
},
}

View File

@@ -75,9 +75,10 @@ func TestCollapsePaths(t *testing.T) {
defer os.Setenv("HOME", origHome)
tests := []struct {
name string
paths []string
contains []string // paths that should be in the result
name string
paths []string
contains []string // paths that should be in the result
notContains []string // paths that must NOT be in the result
}{
{
name: "multiple paths under same app dir",
@@ -111,6 +112,33 @@ func TestCollapsePaths(t *testing.T) {
"/home/testuser/.config/opencode",
},
},
{
name: "files directly under home stay as exact paths",
paths: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
},
contains: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
},
notContains: []string{"/home/testuser"},
},
{
name: "mix of home files and app dir paths",
paths: []string{
"/home/testuser/.gitignore",
"/home/testuser/.cache/opencode/db/main.sqlite",
"/home/testuser/.cache/opencode/version",
"/home/testuser/.npmrc",
},
contains: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
"/home/testuser/.cache/opencode",
},
notContains: []string{"/home/testuser"},
},
}
for _, tt := range tests {
@@ -134,6 +162,13 @@ func TestCollapsePaths(t *testing.T) {
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
}
}
for _, bad := range tt.notContains {
for _, g := range got {
if g == bad {
t.Errorf("CollapsePaths() = %v, should NOT contain %q", got, bad)
}
}
}
})
}
}
@@ -325,8 +360,9 @@ func TestListLearnedTemplates(t *testing.T) {
}
func TestBuildTemplate(t *testing.T) {
allowRead := []string{"~/external-data"}
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
result := buildTemplate("opencode", allowWrite)
result := buildTemplate("opencode", allowRead, allowWrite)
// Check header comments
if !strings.Contains(result, `Learned template for "opencode"`) {
@@ -340,6 +376,12 @@ func TestBuildTemplate(t *testing.T) {
}
// Check content
if !strings.Contains(result, `"allowRead"`) {
t.Error("template missing allowRead field")
}
if !strings.Contains(result, `"~/external-data"`) {
t.Error("template missing expected allowRead path")
}
if !strings.Contains(result, `"allowWrite"`) {
t.Error("template missing allowWrite field")
}
@@ -352,6 +394,22 @@ func TestBuildTemplate(t *testing.T) {
if !strings.Contains(result, `"denyRead"`) {
t.Error("template missing denyRead field")
}
// Check .env patterns are included in denyRead
if !strings.Contains(result, `".env"`) {
t.Error("template missing .env in denyRead")
}
if !strings.Contains(result, `".env.*"`) {
t.Error("template missing .env.* in denyRead")
}
}
func TestBuildTemplateNoAllowRead(t *testing.T) {
result := buildTemplate("simple-cmd", nil, []string{"."})
// When allowRead is nil, it should be omitted from JSON
if strings.Contains(result, `"allowRead"`) {
t.Error("template should omit allowRead when nil")
}
}
func TestGenerateLearnedTemplate(t *testing.T) {

View File

@@ -32,7 +32,7 @@ type ProxyBridge struct {
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
// On the host, socat forwards from the Unix socket to the actual DNS server (TCP).
// On the host, socat forwards from the Unix socket to the actual DNS server (UDP).
type DnsBridge struct {
SocketPath string // Unix socket path
DnsAddr string // Host-side DNS address (host:port)
@@ -61,10 +61,10 @@ func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
debug: debug,
}
// Start bridge: Unix socket -> DNS server TCP
// Start bridge: Unix socket -> DNS server UDP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:%s", dnsAddr),
fmt.Sprintf("UDP:%s", dnsAddr),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
@@ -371,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
paths = append(paths, p)
}
// Sensitive project files (e.g. .env) in cwd
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
paths = append(paths, p)
}
// Git hooks in cwd
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
@@ -389,6 +395,193 @@ func getMandatoryDenyPaths(cwd string) []string {
return paths
}
// 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.
func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []string {
var args []string
home, _ := os.UserHomeDir()
// Start with empty root
args = append(args, "--tmpfs", "/")
// System paths (read-only) - on modern distros (Arch, Fedora, etc.),
// /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must
// recreate these as symlinks via --symlink so the dynamic linker
// and shell can be found. Real directories get bind-mounted.
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run"}
for _, p := range systemPaths {
if !fileExists(p) {
continue
}
if isSymlink(p) {
// Recreate the symlink inside the sandbox (e.g., /bin -> usr/bin)
target, err := os.Readlink(p)
if err == nil {
args = append(args, "--symlink", target, p)
}
} else {
args = append(args, "--ro-bind", p, p)
}
}
// /sys needs to be accessible for system info
if fileExists("/sys") && canMountOver("/sys") {
args = append(args, "--ro-bind", "/sys", "/sys")
}
// CWD: create intermediary dirs and bind read-write
if cwd != "" && fileExists(cwd) {
for _, dir := range intermediaryDirs("/", cwd) {
// Skip dirs that are already mounted as system paths
if isSystemMountPoint(dir) {
continue
}
args = append(args, "--dir", dir)
}
args = append(args, "--bind", cwd, cwd)
}
// User tooling paths from GetDefaultReadablePaths() (read-only)
// Filter out paths already mounted (system dirs, /dev, /proc, /tmp, macOS-specific)
if home != "" {
boundDirs := make(map[string]bool)
for _, p := range GetDefaultReadablePaths() {
// Skip system paths (already bound above), special mounts, and macOS paths
if isSystemMountPoint(p) || p == "/dev" || p == "/proc" || p == "/sys" ||
p == "/tmp" || p == "/private/tmp" ||
strings.HasPrefix(p, "/System") || strings.HasPrefix(p, "/Library") ||
strings.HasPrefix(p, "/Applications") || strings.HasPrefix(p, "/private/") ||
strings.HasPrefix(p, "/nix") || strings.HasPrefix(p, "/snap") ||
p == "/usr/local" || p == "/opt/homebrew" {
continue
}
if !strings.HasPrefix(p, home) {
continue // Only user tooling paths need intermediary dirs
}
if !fileExists(p) || !canMountOver(p) {
continue
}
// Create intermediary dirs between root and this path
for _, dir := range intermediaryDirs("/", p) {
if !boundDirs[dir] && !isSystemMountPoint(dir) && dir != cwd {
boundDirs[dir] = true
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", p, p)
}
// Shell config files in home (read-only, literal files)
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
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)
}
}
homeIntermedaryAdded = true
}
args = append(args, "--ro-bind", p, p)
}
}
// 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)
}
}
homeIntermedaryAdded = true
}
args = append(args, "--ro-bind", p, p)
}
}
}
// 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 _, dir := range intermediaryDirs("/", p) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", p, p)
}
}
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
for _, dir := range intermediaryDirs("/", normalized) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", normalized, normalized)
}
}
}
// Mask sensitive project files within CWD by overlaying an empty regular file.
// We use an empty file instead of /dev/null because Landlock's READ_FILE right
// doesn't cover character devices, causing "Permission denied" on /dev/null mounts.
if cwd != "" {
var emptyFile string
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
if fileExists(p) {
if emptyFile == "" {
emptyFile = filepath.Join(os.TempDir(), "greywall", "empty")
_ = os.MkdirAll(filepath.Dir(emptyFile), 0o750)
_ = os.WriteFile(emptyFile, nil, 0o444)
}
args = append(args, "--ro-bind", emptyFile, p)
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Masking sensitive file: %s\n", p)
}
}
}
}
return args
}
// isSystemMountPoint returns true if the path is a top-level system directory
// that gets mounted directly under --tmpfs / (bwrap auto-creates these).
func isSystemMountPoint(path string) bool {
switch path {
case "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run", "/sys",
"/dev", "/proc", "/tmp",
// macOS
"/System", "/Library", "/Applications", "/private",
// Package managers
"/nix", "/snap", "/usr/local", "/opt/homebrew":
return true
}
return false
}
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
// It uses available security features (Landlock, seccomp) with graceful fallback.
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
@@ -480,52 +673,18 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
}
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if opts.Learning {
// Skip defaultDenyRead logic in learning mode (already set up above)
} else if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
}
// Bind essential system paths read-only
// Skip /dev, /proc, /tmp as they're mounted with special options below
for _, systemPath := range GetDefaultReadablePaths() {
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
systemPath == "/private/tmp" {
continue
}
if fileExists(systemPath) {
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
}
}
// Bind user-specified allowRead paths
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
for _, p := range expandedPaths {
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
}
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
} else {
// Default mode: bind entire root filesystem read-only
// Legacy mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
}
@@ -679,10 +838,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// In deny-by-default mode, sensitive project files are already masked
// with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here
// to avoid overriding the /dev/null mask with a real ro-bind.
maskedPaths := make(map[string]bool)
if defaultDenyRead {
for _, f := range SensitiveProjectFiles {
maskedPaths[filepath.Join(cwd, f)] = true
}
}
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) {
if !seen[p] && fileExists(p) && !maskedPaths[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}

View File

@@ -276,6 +276,107 @@ func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat
}
// PrintDependencyStatus prints dependency status with install suggestions for Linux.
func PrintDependencyStatus() {
features := DetectLinuxFeatures()
fmt.Printf("\n Platform: linux (kernel %d.%d)\n", features.KernelMajor, features.KernelMinor)
fmt.Printf("\n Dependencies (required):\n")
allGood := true
if features.HasBwrap {
fmt.Printf(" ✓ bubblewrap (bwrap)\n")
} else {
fmt.Printf(" ✗ bubblewrap (bwrap) — REQUIRED\n")
allGood = false
}
if features.HasSocat {
fmt.Printf(" ✓ socat\n")
} else {
fmt.Printf(" ✗ socat — REQUIRED\n")
allGood = false
}
if !allGood {
fmt.Printf("\n Install missing dependencies:\n")
fmt.Printf(" %s\n", suggestInstallCmd(features))
}
fmt.Printf("\n Security features: %s\n", features.Summary())
if features.CanUseTransparentProxy() {
fmt.Printf(" Transparent proxy: available\n")
} else {
parts := []string{}
if !features.HasIpCommand {
parts = append(parts, "iproute2")
}
if !features.HasDevNetTun {
parts = append(parts, "/dev/net/tun")
}
if !features.CanUnshareNet {
parts = append(parts, "network namespace")
}
if len(parts) > 0 {
fmt.Printf(" Transparent proxy: unavailable (missing: %s)\n", strings.Join(parts, ", "))
} else {
fmt.Printf(" Transparent proxy: unavailable\n")
}
if !features.CanUnshareNet && features.HasBwrap {
if val := readSysctl("kernel/apparmor_restrict_unprivileged_userns"); val == "1" {
fmt.Printf("\n Note: AppArmor is restricting unprivileged user namespaces.\n")
fmt.Printf(" This prevents bwrap --unshare-net (needed for transparent proxy).\n")
fmt.Printf(" To fix: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n")
fmt.Printf(" Persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf\n")
}
}
}
if allGood {
fmt.Printf("\n Status: ready\n")
} else {
fmt.Printf("\n Status: missing required dependencies\n")
}
}
func suggestInstallCmd(features *LinuxFeatures) string {
var missing []string
if !features.HasBwrap {
missing = append(missing, "bubblewrap")
}
if !features.HasSocat {
missing = append(missing, "socat")
}
pkgs := strings.Join(missing, " ")
switch {
case commandExists("apt-get"):
return fmt.Sprintf("sudo apt install %s", pkgs)
case commandExists("dnf"):
return fmt.Sprintf("sudo dnf install %s", pkgs)
case commandExists("yum"):
return fmt.Sprintf("sudo yum install %s", pkgs)
case commandExists("pacman"):
return fmt.Sprintf("sudo pacman -S %s", pkgs)
case commandExists("apk"):
return fmt.Sprintf("sudo apk add %s", pkgs)
case commandExists("zypper"):
return fmt.Sprintf("sudo zypper install %s", pkgs)
default:
return fmt.Sprintf("install %s using your package manager", pkgs)
}
}
func readSysctl(name string) string {
data, err := os.ReadFile("/proc/sys/" + name)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil

View File

@@ -2,6 +2,12 @@
package sandbox
import (
"fmt"
"os/exec"
"runtime"
)
// LinuxFeatures describes available Linux sandboxing features.
// This is a stub for non-Linux platforms.
type LinuxFeatures struct {
@@ -51,3 +57,21 @@ func (f *LinuxFeatures) CanUseTransparentProxy() bool {
func (f *LinuxFeatures) MinimumViable() bool {
return false
}
// PrintDependencyStatus prints dependency status for non-Linux platforms.
func PrintDependencyStatus() {
if runtime.GOOS == "darwin" {
fmt.Printf("\n Platform: macOS\n")
fmt.Printf("\n Dependencies (required):\n")
if _, err := exec.LookPath("sandbox-exec"); err == nil {
fmt.Printf(" ✓ sandbox-exec (Seatbelt)\n")
fmt.Printf("\n Status: ready\n")
} else {
fmt.Printf(" ✗ sandbox-exec — REQUIRED (should be built-in on macOS)\n")
fmt.Printf("\n Status: missing required dependencies\n")
}
} else {
fmt.Printf("\n Platform: %s (unsupported)\n", runtime.GOOS)
fmt.Printf("\n Status: this platform is not supported\n")
}
}

View File

@@ -81,17 +81,55 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
}
}
// Current working directory - read access (may be upgraded to write below)
// Current working directory - read+write access (project directory)
if cwd != "" {
if err := ruleset.AllowRead(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err)
}
}
// Home directory - read access
if home, err := os.UserHomeDir(); err == nil {
if err := ruleset.AllowRead(home); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
// Home directory - read access only when not in deny-by-default mode.
// In deny-by-default mode, only specific user tooling paths are allowed,
// not the entire home directory. Landlock can't selectively deny files
// within an allowed directory, so we rely on bwrap mount overlays for
// .env file masking.
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if !defaultDenyRead {
if home, err := os.UserHomeDir(); err == nil {
if err := ruleset.AllowRead(home); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
}
}
} else {
// In deny-by-default mode, allow specific user tooling paths
if home, err := os.UserHomeDir(); err == nil {
for _, p := range GetDefaultReadablePaths() {
if strings.HasPrefix(p, home) {
if err := ruleset.AllowRead(p); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add user tooling path %s: %v\n", p, err)
}
}
}
// Shell configs
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
for _, f := range shellConfigs {
p := filepath.Join(home, f)
if err := ruleset.AllowRead(p); err != nil && debug {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add shell config %s: %v\n", p, err)
}
}
}
// Home caches
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
if err := ruleset.AllowRead(p); err != nil && debug {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home cache %s: %v\n", p, err)
}
}
}
}
}

View File

@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
AllowLocalBinding bool
AllowLocalOutbound bool
DefaultDenyRead bool
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
ReadAllowPaths []string
ReadDenyPaths []string
WriteAllowPaths []string
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
}
// generateReadRules generates filesystem read rules for the sandbox profile.
func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, logTag string) []string {
func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, logTag string) []string {
var rules []string
if defaultDenyRead {
// When defaultDenyRead is enabled:
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
// 2. Allow file-read-data only for system paths + user-specified allowRead paths
// 2. Allow file-read-data only for system paths + CWD + user-specified allowRead paths
// This lets programs see what files exist but not read their contents.
// Allow metadata operations globally (stat, readdir, etc.) and root dir (for path resolution)
@@ -167,6 +168,44 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
)
}
// Allow reading CWD (full recursive read access)
if cwd != "" {
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(cwd)),
)
// Allow ancestor directory traversal (literal only, so programs can resolve CWD path)
for _, ancestor := range getAncestorDirectories(cwd) {
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(ancestor)),
)
}
}
// Allow home shell configs and tool caches (read-only)
home, _ := os.UserHomeDir()
if home != "" {
// Shell config files (literal access)
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
for _, f := range shellConfigs {
p := filepath.Join(home, f)
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)),
)
}
// Home tool caches (subpath access for package managers/configs)
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config", ".nvm", ".pyenv", ".rbenv", ".asdf"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(p)),
)
}
}
// Allow reading data from user-specified paths
for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern)
@@ -184,6 +223,24 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
)
}
}
// Deny sensitive files within CWD (Seatbelt evaluates deny before allow)
if cwd != "" {
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (literal %s)", escapePath(p)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Also deny .env.* pattern via regex
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")),
fmt.Sprintf(" (with message %q))", logTag),
)
}
} else {
// Allow all reads by default
rules = append(rules, "(allow file-read*)")
@@ -220,9 +277,19 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
}
// generateWriteRules generates filesystem write rules for the sandbox profile.
func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
// When cwd is non-empty, it is automatically included in the write allow paths.
func generateWriteRules(cwd string, allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
var rules []string
// Auto-allow CWD for writes (project directory should be writable)
if cwd != "" {
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(cwd)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Allow TMPDIR parent on macOS
for _, tmpdirParent := range getTmpdirParent() {
normalized := NormalizePath(tmpdirParent)
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
}
// Combine user-specified and mandatory deny patterns
cwd, _ := os.Getwd()
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig)
mandatoryCwd := cwd
if mandatoryCwd == "" {
mandatoryCwd, _ = os.Getwd()
}
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
allDenyPaths = append(allDenyPaths, denyPaths...)
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// Read rules
profile.WriteString("; File read\n")
for _, rule := range generateReadRules(params.DefaultDenyRead, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
profile.WriteString(rule + "\n")
}
profile.WriteString("\n")
// Write rules
profile.WriteString("; File write\n")
for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
for _, rule := range generateWriteRules(params.Cwd, params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
profile.WriteString(rule + "\n")
}
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
cwd, _ := os.Getwd()
// Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: cwd,
ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,

View File

@@ -1,6 +1,7 @@
package sandbox
import (
"fmt"
"strings"
"testing"
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: "/tmp/test-project",
ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
tests := []struct {
name string
defaultDenyRead bool
cwd string
allowRead []string
wantContainsBlanketAllow bool
wantContainsMetadataAllow bool
wantContainsSystemAllows bool
wantContainsUserAllowRead bool
wantContainsCwdAllow bool
}{
{
name: "default mode - blanket allow read",
name: "legacy mode - blanket allow read",
defaultDenyRead: false,
cwd: "/home/user/project",
allowRead: nil,
wantContainsBlanketAllow: true,
wantContainsMetadataAllow: false,
wantContainsSystemAllows: false,
wantContainsUserAllowRead: false,
wantContainsCwdAllow: false,
},
{
name: "defaultDenyRead enabled - metadata allow, system data allows",
name: "defaultDenyRead enabled - metadata allow, system data allows, CWD allow",
defaultDenyRead: true,
cwd: "/home/user/project",
allowRead: nil,
wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true,
wantContainsSystemAllows: true,
wantContainsUserAllowRead: false,
wantContainsCwdAllow: true,
},
{
name: "defaultDenyRead with allowRead paths",
defaultDenyRead: true,
allowRead: []string{"/home/user/project"},
cwd: "/home/user/project",
allowRead: []string{"/home/user/other"},
wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true,
wantContainsSystemAllows: true,
wantContainsUserAllowRead: true,
wantContainsCwdAllow: true,
},
}
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
DefaultDenyRead: tt.defaultDenyRead,
Cwd: tt.cwd,
ReadAllowPaths: tt.allowRead,
}
@@ -236,6 +247,13 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
}
if tt.wantContainsCwdAllow && tt.cwd != "" {
hasCwdAllow := strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, tt.cwd))
if !hasCwdAllow {
t.Errorf("CWD path %q not found in profile", tt.cwd)
}
}
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
if !hasUserAllow {