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
+116 -45
View File
@@ -5,77 +5,148 @@ import (
"path/filepath"
)
// DANGEROUS_FILES are files that should always be blocked from write access
// to prevent credential theft and security compromise.
// DANGEROUS_FILES are credential and config files blocked by default.
// Users opt out via allow_read / allow_write (see GetMandatoryDenyPatterns).
var DANGEROUS_FILES = []string{
".env",
".env.*",
".aws",
".azure",
".gcloud",
".config/gcloud",
".kube",
".ssh",
".gnupg",
".docker/config.json",
".netrc",
".git-credentials",
".pgpass",
".config/gh",
}
// GetMandatoryDenyPatterns returns filesystem paths that should always be blocked
// from write access for security reasons. These are automatically injected into
// all sandbox policies regardless of user configuration.
// MandatoryDenyOptions configures GetMandatoryDenyPatterns. AllowRead and
// AllowWrite must be already expanded (post-ExpandVariables); the function
// does not call ExpandVariables itself.
type MandatoryDenyOptions struct {
AllowGitConfig bool
AllowRead []string
AllowWrite []string
}
// MandatoryDenyResult splits mandatory denies by direction and reports the
// patterns the user opted out of (for audit logging by translators).
type MandatoryDenyResult struct {
DenyRead []string
DenyWrite []string
SuppressedRead []string
SuppressedWrite []string
}
// GetMandatoryDenyPatterns returns mandatory deny patterns for both directions,
// suppressing any pattern the user has explicitly named in the corresponding
// allow list. Suppression is exact post-expansion byte-equal match — broad
// globs in user allow lists do not suppress.
//
// Parameters:
// - allowGitConfig: if false, blocks write access to .git/config (recommended)
//
// Returns patterns in both absolute (from HOME) and glob forms for comprehensive coverage.
func GetMandatoryDenyPatterns(allowGitConfig bool) []string {
patterns := []string{}
// .git/hooks is never suppressed (arbitrary code execution risk).
// .git/config is emitted only when !AllowGitConfig and may be suppressed.
func GetMandatoryDenyPatterns(opts MandatoryDenyOptions) MandatoryDenyResult {
allowReadSet := toSet(opts.AllowRead)
allowWriteSet := toSet(opts.AllowWrite)
// Get current working directory for CWD-relative patterns
cwd, err := os.Getwd()
if err != nil {
// Fallback to basic patterns if we can't get CWD
cwd = "."
}
// Get home directory for HOME-relative patterns
home, err := os.UserHomeDir()
if err != nil {
// If we can't get home, skip home-based patterns
home = ""
}
// Add dangerous files from CWD
// Naming an absolute form (CWD or HOME) of a dangerous file also suppresses
// the corresponding "**/<file>" glob on the same direction — otherwise the
// glob deny would still block the user's explicit opt-out. The unnamed
// absolute form remains mandatory.
absToDangerous := make(map[string]string)
for _, fileName := range DANGEROUS_FILES {
// Absolute path in CWD
patterns = append(patterns, filepath.Join(cwd, fileName))
// Glob pattern to catch in subdirectories
patterns = append(patterns, filepath.Join("**", fileName))
}
// Add dangerous files from HOME (if available)
if home != "" {
for _, fileName := range DANGEROUS_FILES {
patterns = append(patterns, filepath.Join(home, fileName))
}
}
// Git hooks are blocked in CWD and HOME for security (can execute arbitrary code)
// We don't use global globs like **/.git/hooks to allow legitimate temp dir operations
// (e.g., npx cloning repos to /tmp)
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
patterns = append(patterns, filepath.Join(cwd, ".git/hooks/**"))
if home != "" {
patterns = append(patterns, filepath.Join(home, ".git/hooks"))
patterns = append(patterns, filepath.Join(home, ".git/hooks/**"))
}
// Git config is conditionally blocked in CWD and HOME
if !allowGitConfig {
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
absToDangerous[filepath.Clean(filepath.Join(cwd, fileName))] = fileName
if home != "" {
patterns = append(patterns, filepath.Join(home, ".git/config"))
absToDangerous[filepath.Clean(filepath.Join(home, fileName))] = fileName
}
}
return patterns
readGlobAlsoSuppressed := make(map[string]bool)
for entry := range allowReadSet {
if fileName, ok := absToDangerous[entry]; ok {
readGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true
}
}
writeGlobAlsoSuppressed := make(map[string]bool)
for entry := range allowWriteSet {
if fileName, ok := absToDangerous[entry]; ok {
writeGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true
}
}
suppressible := []string{}
for _, fileName := range DANGEROUS_FILES {
suppressible = append(suppressible, filepath.Join(cwd, fileName))
suppressible = append(suppressible, filepath.Join("**", fileName))
if home != "" {
suppressible = append(suppressible, filepath.Join(home, fileName))
}
}
if !opts.AllowGitConfig {
suppressible = append(suppressible, filepath.Join(cwd, ".git/config"))
if home != "" {
suppressible = append(suppressible, filepath.Join(home, ".git/config"))
}
}
result := MandatoryDenyResult{}
for _, pattern := range suppressible {
cleaned := filepath.Clean(pattern)
if allowReadSet[cleaned] || readGlobAlsoSuppressed[cleaned] {
result.SuppressedRead = append(result.SuppressedRead, cleaned)
} else {
result.DenyRead = append(result.DenyRead, cleaned)
}
if allowWriteSet[cleaned] || writeGlobAlsoSuppressed[cleaned] {
result.SuppressedWrite = append(result.SuppressedWrite, cleaned)
} else {
result.DenyWrite = append(result.DenyWrite, cleaned)
}
}
// Git hooks can execute arbitrary code; never suppressible.
gitHooks := []string{
filepath.Join(cwd, ".git/hooks"),
filepath.Join(cwd, ".git/hooks/**"),
}
if home != "" {
gitHooks = append(gitHooks,
filepath.Join(home, ".git/hooks"),
filepath.Join(home, ".git/hooks/**"),
)
}
for _, p := range gitHooks {
cleaned := filepath.Clean(p)
result.DenyRead = append(result.DenyRead, cleaned)
result.DenyWrite = append(result.DenyWrite, cleaned)
}
return result
}
func toSet(s []string) map[string]bool {
m := make(map[string]bool, len(s))
for _, v := range s {
m[filepath.Clean(v)] = true
}
return m
}