feat/sandbox allow explicit dangerous pattern override (#239)

* feat(sandbox): allow opt-out of mandatory deny via explicit allow rules

Mandatory deny patterns (.env, .aws, .ssh, .gcloud, .kube, .gnupg,
.docker/config.json, .git/config) can now be opted out by listing the
exact literal post-expansion path in policy filesystem.allow_read /
allow_write, OR via --sandbox-allow read=... / write=... at runtime.
Both channels are treated at par.

Suppression is exact-match. Listing the CWD-absolute or HOME-absolute
form of a dangerous file additionally suppresses its **/<file> glob
sibling on the same direction so a single opt-out is sufficient.
Broad globs (${CWD}/**) and relative paths in user allow lists do not
suppress. The unnamed absolute form remains denied. .git/hooks is
unconditional and never suppressible (arbitrary code execution risk).

GetMandatoryDenyPatterns now returns split DenyRead / DenyWrite
slices and reports SuppressedRead / SuppressedWrite for audit. Both
translators emit per-direction deny rules and log.Warnf each
suppression. On Linux/bubblewrap, the tmpfs hide is restricted to the
intersection of DenyRead and DenyWrite; one-sided suppression falls
back to /dev/null (write) or the user's allow_read --ro-bind (read).
bwrap has no primitive that allows writes while denying reads, so
write-only opt-outs warn that the read-side mandatory deny is
unenforceable.

Updates docs/sandbox.md to document the opt-out, exact-match
semantics, and the Linux platform limitation. Updates pmg-e2e.yml to
create ./.env so the sandbox e2e test exercises the BLOCK case.

Closes #232

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Code review fixes

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-05-06 12:45:36 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent b56a8e2a43
commit d6755d3f44
12 changed files with 813 additions and 153 deletions
+216 -73
View File
@@ -6,101 +6,244 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetMandatoryDenyPatterns(t *testing.T) {
t.Run("always blocks dangerous files", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
func emptyOpts() MandatoryDenyOptions {
return MandatoryDenyOptions{AllowGitConfig: false}
}
// Should contain patterns for each dangerous file
assert.Contains(t, patterns, "**/.env")
assert.Contains(t, patterns, "**/.ssh")
assert.Contains(t, patterns, "**/.aws")
assert.Contains(t, patterns, "**/.gcloud")
assert.Contains(t, patterns, "**/.kube")
assert.Contains(t, patterns, "**/.gnupg")
assert.Contains(t, patterns, "**/.docker/config.json")
func TestGetMandatoryDenyPatterns_NoAllowList(t *testing.T) {
t.Run("blocks dangerous file globs on both sides", func(t *testing.T) {
r := GetMandatoryDenyPatterns(emptyOpts())
for _, p := range []string{
"**/.env", "**/.env.*", "**/.ssh", "**/.aws", "**/.azure",
"**/.gcloud", "**/.config/gcloud", "**/.kube", "**/.gnupg",
"**/.docker/config.json", "**/.netrc", "**/.git-credentials",
"**/.pgpass", "**/.config/gh",
} {
assert.Contains(t, r.DenyRead, p, "DenyRead missing %s", p)
assert.Contains(t, r.DenyWrite, p, "DenyWrite missing %s", p)
}
assert.Empty(t, r.SuppressedRead)
assert.Empty(t, r.SuppressedWrite)
})
t.Run("always blocks git hooks in CWD and HOME", func(t *testing.T) {
t.Run("blocks git hooks unconditionally on both sides", func(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
require.NoError(t, err)
home, err := os.UserHomeDir()
assert.NoError(t, err)
require.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
r := GetMandatoryDenyPatterns(emptyOpts())
// Should block git hooks in CWD
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks/**"))
// Should block git hooks in HOME
assert.Contains(t, patterns, filepath.Join(home, ".git/hooks"))
assert.Contains(t, patterns, filepath.Join(home, ".git/hooks/**"))
})
t.Run("blocks git config when allowGitConfig is false", func(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
home, err := os.UserHomeDir()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should block git config in CWD and HOME
assert.Contains(t, patterns, filepath.Join(cwd, ".git/config"))
assert.Contains(t, patterns, filepath.Join(home, ".git/config"))
})
t.Run("allows git config when allowGitConfig is true", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(true)
// Should NOT block git config
for _, pattern := range patterns {
assert.NotContains(t, pattern, ".git/config")
for _, p := range []string{
filepath.Join(cwd, ".git/hooks"),
filepath.Join(cwd, ".git/hooks/**"),
filepath.Join(home, ".git/hooks"),
filepath.Join(home, ".git/hooks/**"),
} {
assert.Contains(t, r.DenyRead, p)
assert.Contains(t, r.DenyWrite, p)
}
})
t.Run("includes CWD-relative patterns", func(t *testing.T) {
t.Run("blocks git config when AllowGitConfig is false", func(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
// Should include absolute paths in CWD
assert.Contains(t, patterns, filepath.Join(cwd, ".env"))
assert.Contains(t, patterns, filepath.Join(cwd, ".ssh"))
assert.Contains(t, patterns, filepath.Join(cwd, ".git/hooks"))
})
t.Run("includes HOME-relative patterns", func(t *testing.T) {
require.NoError(t, err)
home, err := os.UserHomeDir()
assert.NoError(t, err)
require.NoError(t, err)
patterns := GetMandatoryDenyPatterns(false)
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: false})
// Should include absolute paths in HOME
assert.Contains(t, patterns, filepath.Join(home, ".env"))
assert.Contains(t, patterns, filepath.Join(home, ".ssh"))
assert.Contains(t, patterns, filepath.Join(home, ".aws"))
assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".git/config"))
assert.Contains(t, r.DenyWrite, filepath.Join(home, ".git/config"))
assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".git/config"))
assert.Contains(t, r.DenyRead, filepath.Join(home, ".git/config"))
})
t.Run("includes glob patterns for env variants", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
t.Run("omits git config when AllowGitConfig is true", func(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
// Should include pattern for .env.* files
assert.Contains(t, patterns, "**/.env.*")
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{AllowGitConfig: true})
cwdGitConfig := filepath.Join(cwd, ".git/config")
homeGitConfig := filepath.Join(home, ".git/config")
assert.NotContains(t, r.DenyRead, cwdGitConfig)
assert.NotContains(t, r.DenyRead, homeGitConfig)
assert.NotContains(t, r.DenyWrite, cwdGitConfig)
assert.NotContains(t, r.DenyWrite, homeGitConfig)
})
t.Run("includes CWD-absolute and HOME-absolute forms", func(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
r := GetMandatoryDenyPatterns(emptyOpts())
assert.Contains(t, r.DenyRead, filepath.Join(cwd, ".env"))
assert.Contains(t, r.DenyRead, filepath.Join(home, ".env"))
assert.Contains(t, r.DenyWrite, filepath.Join(cwd, ".aws"))
assert.Contains(t, r.DenyWrite, filepath.Join(home, ".aws"))
})
t.Run("does not use global globs for git operations", func(t *testing.T) {
patterns := GetMandatoryDenyPatterns(false)
r := GetMandatoryDenyPatterns(emptyOpts())
// Should NOT contain global globs for git hooks/config
// This allows legitimate git operations in temp directories (e.g., npx cloning repos)
assert.NotContains(t, patterns, "**/.git/hooks")
assert.NotContains(t, patterns, "**/.git/hooks/**")
assert.NotContains(t, patterns, "**/.git/config")
for _, side := range [][]string{r.DenyRead, r.DenyWrite} {
assert.NotContains(t, side, "**/.git/hooks")
assert.NotContains(t, side, "**/.git/hooks/**")
assert.NotContains(t, side, "**/.git/config")
}
})
}
func TestGetMandatoryDenyPatterns_Suppression(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
home, err := os.UserHomeDir()
require.NoError(t, err)
cwdEnv := filepath.Join(cwd, ".env")
homeEnv := filepath.Join(home, ".env")
globEnv := filepath.Join("**", ".env")
homeAws := filepath.Join(home, ".aws")
cwdGitConfig := filepath.Join(cwd, ".git/config")
t.Run("CWD-absolute form suppresses CWD form and glob form on same direction", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{cwdEnv},
})
assert.NotContains(t, r.DenyRead, cwdEnv)
assert.Contains(t, r.SuppressedRead, cwdEnv)
assert.NotContains(t, r.DenyRead, globEnv)
assert.Contains(t, r.SuppressedRead, globEnv)
assert.Contains(t, r.DenyRead, homeEnv)
assert.Contains(t, r.DenyWrite, cwdEnv)
assert.Contains(t, r.DenyWrite, globEnv)
assert.Contains(t, r.DenyWrite, homeEnv)
assert.Empty(t, r.SuppressedWrite)
})
t.Run("HOME-absolute form suppresses HOME form and glob form on same direction", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{homeAws},
})
homeAwsGlob := filepath.Join("**", ".aws")
cwdAws := filepath.Join(cwd, ".aws")
assert.NotContains(t, r.DenyRead, homeAws)
assert.Contains(t, r.SuppressedRead, homeAws)
assert.NotContains(t, r.DenyRead, homeAwsGlob)
assert.Contains(t, r.SuppressedRead, homeAwsGlob)
assert.Contains(t, r.DenyRead, cwdAws)
})
t.Run("glob form suppressed only when listed", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{globEnv},
})
assert.NotContains(t, r.DenyRead, globEnv)
assert.Contains(t, r.SuppressedRead, globEnv)
assert.Contains(t, r.DenyRead, cwdEnv)
assert.Contains(t, r.DenyRead, homeEnv)
})
t.Run("read-side suppression does not affect write side", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{cwdEnv},
AllowWrite: []string{},
})
assert.NotContains(t, r.DenyRead, cwdEnv)
assert.Contains(t, r.DenyWrite, cwdEnv)
})
t.Run("write-side suppression does not affect read side", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowWrite: []string{cwdEnv},
})
assert.NotContains(t, r.DenyWrite, cwdEnv)
assert.Contains(t, r.DenyRead, cwdEnv)
})
t.Run("broad glob does NOT suppress", func(t *testing.T) {
broad := filepath.Join(cwd, "**")
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{broad},
})
assert.Empty(t, r.SuppressedRead)
assert.Contains(t, r.DenyRead, cwdEnv)
})
t.Run("relative path in allow list does NOT suppress absolute form", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{".env"}, // post-Clean stays as ".env"
})
assert.Contains(t, r.DenyRead, cwdEnv)
assert.Empty(t, r.SuppressedRead)
})
t.Run("git config CWD form suppressible via allow_write", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowGitConfig: false,
AllowWrite: []string{cwdGitConfig},
})
assert.NotContains(t, r.DenyWrite, cwdGitConfig)
assert.Contains(t, r.SuppressedWrite, cwdGitConfig)
})
t.Run("git hooks NEVER suppressed", func(t *testing.T) {
cwdHooks := filepath.Join(cwd, ".git/hooks")
cwdHooksGlob := filepath.Join(cwd, ".git/hooks/**")
homeHooks := filepath.Join(home, ".git/hooks")
homeHooksGlob := filepath.Join(home, ".git/hooks/**")
hookPaths := []string{cwdHooks, cwdHooksGlob, homeHooks, homeHooksGlob}
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: hookPaths,
AllowWrite: hookPaths,
})
for _, p := range hookPaths {
assert.Contains(t, r.DenyRead, p)
assert.Contains(t, r.DenyWrite, p)
assert.NotContains(t, r.SuppressedRead, p)
assert.NotContains(t, r.SuppressedWrite, p)
}
})
t.Run("multiple suppressions accumulate", func(t *testing.T) {
r := GetMandatoryDenyPatterns(MandatoryDenyOptions{
AllowRead: []string{cwdEnv, globEnv},
AllowWrite: []string{cwdEnv},
})
// AllowWrite lists only cwdEnv; the absolute form auto-suppresses the
// **/.env glob on that side too.
assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedRead)
assert.ElementsMatch(t, []string{cwdEnv, globEnv}, r.SuppressedWrite)
})
}