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
This commit is contained in:
@@ -36,7 +36,7 @@ type NetworkConfig struct {
|
|||||||
|
|
||||||
// FilesystemConfig defines filesystem restrictions.
|
// FilesystemConfig defines filesystem restrictions.
|
||||||
type FilesystemConfig struct {
|
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)
|
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
|
||||||
DenyRead []string `json:"denyRead"`
|
DenyRead []string `json:"denyRead"`
|
||||||
AllowWrite []string `json:"allowWrite"`
|
AllowWrite []string `json:"allowWrite"`
|
||||||
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
|
|||||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
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.
|
// CommandConfig defines command restrictions.
|
||||||
type CommandConfig struct {
|
type CommandConfig struct {
|
||||||
Deny []string `json:"deny"`
|
Deny []string `json:"deny"`
|
||||||
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
// Boolean fields: true if either enables it
|
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
|
||||||
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
|
||||||
|
|
||||||
// Append slices
|
// Append slices
|
||||||
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
|
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
|
|||||||
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
||||||
base := &Config{
|
base := &Config{
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
DefaultDenyRead: true,
|
DefaultDenyRead: boolPtr(true),
|
||||||
AllowRead: []string{"/home/user/project"},
|
AllowRead: []string{"/home/user/project"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
result := Merge(base, override)
|
result := Merge(base, override)
|
||||||
|
|
||||||
if !result.Filesystem.DefaultDenyRead {
|
if !result.Filesystem.IsDefaultDenyRead() {
|
||||||
t.Error("expected DefaultDenyRead to be true (from base)")
|
t.Error("expected IsDefaultDenyRead() to be true (from base)")
|
||||||
}
|
}
|
||||||
if len(result.Filesystem.AllowRead) != 2 {
|
if len(result.Filesystem.AllowRead) != 2 {
|
||||||
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
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 {
|
func boolPtr(b bool) *bool {
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
|
|||||||
".claude/agents",
|
".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.
|
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
|
||||||
func GetDefaultWritePaths() []string {
|
func GetDefaultWritePaths() []string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|||||||
@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// testConfig creates a test configuration with sensible defaults.
|
// 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 {
|
func testConfig() *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Network: config.NetworkConfig{},
|
Network: config.NetworkConfig{},
|
||||||
Filesystem: config.FilesystemConfig{
|
Filesystem: config.FilesystemConfig{
|
||||||
DenyRead: []string{},
|
DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
|
||||||
AllowWrite: []string{},
|
DenyRead: []string{},
|
||||||
DenyWrite: []string{},
|
AllowWrite: []string{},
|
||||||
|
DenyWrite: []string{},
|
||||||
},
|
},
|
||||||
Command: config.CommandConfig{
|
Command: config.CommandConfig{
|
||||||
Deny: []string{},
|
Deny: []string{},
|
||||||
|
|||||||
@@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
|
|||||||
allowWrite = append(allowWrite, toTildePath(p, home))
|
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
|
// Convert read paths to tilde-relative for display
|
||||||
var readDisplay []string
|
var readDisplay []string
|
||||||
for _, p := range result.ReadPaths {
|
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
|
if len(allowWrite) > 1 { // >1 because "." is always included
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
|
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
|
||||||
for _, p := range allowWrite {
|
for _, p := range allowWrite {
|
||||||
@@ -118,7 +162,7 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
|
|||||||
fmt.Fprintf(os.Stderr, "\n")
|
fmt.Fprintf(os.Stderr, "\n")
|
||||||
|
|
||||||
// Build template
|
// Build template
|
||||||
template := buildTemplate(cmdName, allowWrite)
|
template := buildTemplate(cmdName, allowRead, allowWrite)
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
templatePath := LearnedTemplatePath(cmdName)
|
templatePath := LearnedTemplatePath(cmdName)
|
||||||
@@ -345,9 +389,18 @@ func deduplicateSubPaths(paths []string) []string {
|
|||||||
return result
|
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.
|
// 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 {
|
type fsConfig struct {
|
||||||
|
AllowRead []string `json:"allowRead,omitempty"`
|
||||||
AllowWrite []string `json:"allowWrite"`
|
AllowWrite []string `json:"allowWrite"`
|
||||||
DenyWrite []string `json:"denyWrite"`
|
DenyWrite []string `json:"denyWrite"`
|
||||||
DenyRead []string `json:"denyRead"`
|
DenyRead []string `json:"denyRead"`
|
||||||
@@ -356,11 +409,15 @@ func buildTemplate(cmdName string, allowWrite []string) string {
|
|||||||
Filesystem fsConfig `json:"filesystem"`
|
Filesystem fsConfig `json:"filesystem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combine sensitive read patterns with .env project patterns
|
||||||
|
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
|
||||||
|
|
||||||
cfg := templateConfig{
|
cfg := templateConfig{
|
||||||
Filesystem: fsConfig{
|
Filesystem: fsConfig{
|
||||||
|
AllowRead: allowRead,
|
||||||
AllowWrite: allowWrite,
|
AllowWrite: allowWrite,
|
||||||
DenyWrite: getDangerousFilePatterns(),
|
DenyWrite: getDangerousFilePatterns(),
|
||||||
DenyRead: getSensitiveReadPatterns(),
|
DenyRead: denyRead,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,8 +325,9 @@ func TestListLearnedTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildTemplate(t *testing.T) {
|
func TestBuildTemplate(t *testing.T) {
|
||||||
|
allowRead := []string{"~/external-data"}
|
||||||
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
||||||
result := buildTemplate("opencode", allowWrite)
|
result := buildTemplate("opencode", allowRead, allowWrite)
|
||||||
|
|
||||||
// Check header comments
|
// Check header comments
|
||||||
if !strings.Contains(result, `Learned template for "opencode"`) {
|
if !strings.Contains(result, `Learned template for "opencode"`) {
|
||||||
@@ -340,6 +341,12 @@ func TestBuildTemplate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check content
|
// 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"`) {
|
if !strings.Contains(result, `"allowWrite"`) {
|
||||||
t.Error("template missing allowWrite field")
|
t.Error("template missing allowWrite field")
|
||||||
}
|
}
|
||||||
@@ -352,6 +359,22 @@ func TestBuildTemplate(t *testing.T) {
|
|||||||
if !strings.Contains(result, `"denyRead"`) {
|
if !strings.Contains(result, `"denyRead"`) {
|
||||||
t.Error("template missing denyRead field")
|
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) {
|
func TestGenerateLearnedTemplate(t *testing.T) {
|
||||||
|
|||||||
@@ -371,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
|
|||||||
paths = append(paths, p)
|
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
|
// Git hooks in cwd
|
||||||
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
|
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
|
||||||
|
|
||||||
@@ -389,6 +395,193 @@ func getMandatoryDenyPaths(cwd string) []string {
|
|||||||
return paths
|
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.
|
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
// 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) {
|
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 {
|
if opts.Learning {
|
||||||
// Skip defaultDenyRead logic in learning mode (already set up above)
|
// Skip defaultDenyRead logic in learning mode (already set up above)
|
||||||
} else if defaultDenyRead {
|
} else if defaultDenyRead {
|
||||||
// In defaultDenyRead mode, we only bind essential system paths read-only
|
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
|
||||||
// and user-specified allowRead paths. Everything else is inaccessible.
|
|
||||||
if opts.Debug {
|
if opts.Debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
|
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
|
||||||
} else {
|
} else {
|
||||||
// Default mode: bind entire root filesystem read-only
|
// Legacy mode: bind entire root filesystem read-only
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
|
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.
|
// subdirectory dangerous files without full tree walks that hang on large dirs.
|
||||||
mandatoryDeny := getMandatoryDenyPaths(cwd)
|
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
|
// Deduplicate
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, p := range mandatoryDeny {
|
for _, p := range mandatoryDeny {
|
||||||
if !seen[p] && fileExists(p) {
|
if !seen[p] && fileExists(p) && !maskedPaths[p] {
|
||||||
seen[p] = true
|
seen[p] = true
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 cwd != "" {
|
||||||
if err := ruleset.AllowRead(cwd); err != nil && debug {
|
if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
|
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home directory - read access
|
// Home directory - read access only when not in deny-by-default mode.
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
// In deny-by-default mode, only specific user tooling paths are allowed,
|
||||||
if err := ruleset.AllowRead(home); err != nil && debug {
|
// not the entire home directory. Landlock can't selectively deny files
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
|
|||||||
AllowLocalBinding bool
|
AllowLocalBinding bool
|
||||||
AllowLocalOutbound bool
|
AllowLocalOutbound bool
|
||||||
DefaultDenyRead bool
|
DefaultDenyRead bool
|
||||||
|
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
|
||||||
ReadAllowPaths []string
|
ReadAllowPaths []string
|
||||||
ReadDenyPaths []string
|
ReadDenyPaths []string
|
||||||
WriteAllowPaths []string
|
WriteAllowPaths []string
|
||||||
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateReadRules generates filesystem read rules for the sandbox profile.
|
// 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
|
var rules []string
|
||||||
|
|
||||||
if defaultDenyRead {
|
if defaultDenyRead {
|
||||||
// When defaultDenyRead is enabled:
|
// When defaultDenyRead is enabled:
|
||||||
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
|
// 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.
|
// 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)
|
// 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
|
// Allow reading data from user-specified paths
|
||||||
for _, pathPattern := range allowPaths {
|
for _, pathPattern := range allowPaths {
|
||||||
normalized := NormalizePath(pathPattern)
|
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 {
|
} else {
|
||||||
// Allow all reads by default
|
// Allow all reads by default
|
||||||
rules = append(rules, "(allow file-read*)")
|
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.
|
// 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
|
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
|
// Allow TMPDIR parent on macOS
|
||||||
for _, tmpdirParent := range getTmpdirParent() {
|
for _, tmpdirParent := range getTmpdirParent() {
|
||||||
normalized := NormalizePath(tmpdirParent)
|
normalized := NormalizePath(tmpdirParent)
|
||||||
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combine user-specified and mandatory deny patterns
|
// Combine user-specified and mandatory deny patterns
|
||||||
cwd, _ := os.Getwd()
|
mandatoryCwd := cwd
|
||||||
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig)
|
if mandatoryCwd == "" {
|
||||||
|
mandatoryCwd, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
|
||||||
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
|
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
|
||||||
allDenyPaths = append(allDenyPaths, denyPaths...)
|
allDenyPaths = append(allDenyPaths, denyPaths...)
|
||||||
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
|
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
|
||||||
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
|
|
||||||
// Read rules
|
// Read rules
|
||||||
profile.WriteString("; File read\n")
|
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(rule + "\n")
|
||||||
}
|
}
|
||||||
profile.WriteString("\n")
|
profile.WriteString("\n")
|
||||||
|
|
||||||
// Write rules
|
// Write rules
|
||||||
profile.WriteString("; File write\n")
|
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")
|
profile.WriteString(rule + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
|
|
||||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
// Build allow paths: default + configured
|
// Build allow paths: default + configured
|
||||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||||
|
|
||||||
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
|||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
AllowLocalBinding: allowLocalBinding,
|
||||||
AllowLocalOutbound: allowLocalOutbound,
|
AllowLocalOutbound: allowLocalOutbound,
|
||||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||||
|
Cwd: cwd,
|
||||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||||
WriteAllowPaths: allowPaths,
|
WriteAllowPaths: allowPaths,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
|||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
AllowLocalBinding: allowLocalBinding,
|
||||||
AllowLocalOutbound: allowLocalOutbound,
|
AllowLocalOutbound: allowLocalOutbound,
|
||||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||||
|
Cwd: "/tmp/test-project",
|
||||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||||
WriteAllowPaths: allowPaths,
|
WriteAllowPaths: allowPaths,
|
||||||
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
defaultDenyRead bool
|
defaultDenyRead bool
|
||||||
|
cwd string
|
||||||
allowRead []string
|
allowRead []string
|
||||||
wantContainsBlanketAllow bool
|
wantContainsBlanketAllow bool
|
||||||
wantContainsMetadataAllow bool
|
wantContainsMetadataAllow bool
|
||||||
wantContainsSystemAllows bool
|
wantContainsSystemAllows bool
|
||||||
wantContainsUserAllowRead bool
|
wantContainsUserAllowRead bool
|
||||||
|
wantContainsCwdAllow bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default mode - blanket allow read",
|
name: "legacy mode - blanket allow read",
|
||||||
defaultDenyRead: false,
|
defaultDenyRead: false,
|
||||||
|
cwd: "/home/user/project",
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: true,
|
wantContainsBlanketAllow: true,
|
||||||
wantContainsMetadataAllow: false,
|
wantContainsMetadataAllow: false,
|
||||||
wantContainsSystemAllows: false,
|
wantContainsSystemAllows: false,
|
||||||
wantContainsUserAllowRead: 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,
|
defaultDenyRead: true,
|
||||||
|
cwd: "/home/user/project",
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: false,
|
wantContainsBlanketAllow: false,
|
||||||
wantContainsMetadataAllow: true,
|
wantContainsMetadataAllow: true,
|
||||||
wantContainsSystemAllows: true,
|
wantContainsSystemAllows: true,
|
||||||
wantContainsUserAllowRead: false,
|
wantContainsUserAllowRead: false,
|
||||||
|
wantContainsCwdAllow: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "defaultDenyRead with allowRead paths",
|
name: "defaultDenyRead with allowRead paths",
|
||||||
defaultDenyRead: true,
|
defaultDenyRead: true,
|
||||||
allowRead: []string{"/home/user/project"},
|
cwd: "/home/user/project",
|
||||||
|
allowRead: []string{"/home/user/other"},
|
||||||
wantContainsBlanketAllow: false,
|
wantContainsBlanketAllow: false,
|
||||||
wantContainsMetadataAllow: true,
|
wantContainsMetadataAllow: true,
|
||||||
wantContainsSystemAllows: true,
|
wantContainsSystemAllows: true,
|
||||||
wantContainsUserAllowRead: true,
|
wantContainsUserAllowRead: true,
|
||||||
|
wantContainsCwdAllow: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
params := MacOSSandboxParams{
|
params := MacOSSandboxParams{
|
||||||
Command: "echo test",
|
Command: "echo test",
|
||||||
DefaultDenyRead: tt.defaultDenyRead,
|
DefaultDenyRead: tt.defaultDenyRead,
|
||||||
|
Cwd: tt.cwd,
|
||||||
ReadAllowPaths: tt.allowRead,
|
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)
|
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 {
|
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||||
if !hasUserAllow {
|
if !hasUserAllow {
|
||||||
|
|||||||
Reference in New Issue
Block a user