mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for sandbox allow override (#165)
* feat: Add support for sandbox allow override * fix: Main should fail on arg processing error * fix: Remove redundant policy conflict check
This commit is contained in:
+27
-1
@@ -1,6 +1,13 @@
|
||||
package config
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
||||
var sandboxAllowRaw []string
|
||||
|
||||
// ApplyCobraFlags applies the cobra flags to the command.
|
||||
// These flags are local concern of the config package. This helper function is used
|
||||
@@ -29,7 +36,26 @@ func ApplyCobraFlags(cmd *cobra.Command) {
|
||||
globalConfig.Config.Sandbox.EnforceAlways, "Apply sandbox to all commands, not just install commands (requires --sandbox)")
|
||||
cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile",
|
||||
globalConfig.SandboxProfileOverride, "Override sandbox policy profile (built-in name or path to custom YAML)")
|
||||
cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow",
|
||||
nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind")
|
||||
|
||||
// Hide the experimental proxy mode flag but keep it for backward compatibility
|
||||
_ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode")
|
||||
}
|
||||
|
||||
// FinalizeSandboxAllowOverrides parses the raw --sandbox-allow flag values
|
||||
// and stores the validated overrides in the global config. This must be called
|
||||
// after cobra flag parsing is complete (e.g., in PersistentPreRun).
|
||||
func FinalizeSandboxAllowOverrides() error {
|
||||
if len(sandboxAllowRaw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
overrides, err := parseSandboxAllowOverrides(sandboxAllowRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse --sandbox-allow flags: %w", err)
|
||||
}
|
||||
|
||||
globalConfig.SandboxAllowOverrides = overrides
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ type RuntimeConfig struct {
|
||||
// This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml.
|
||||
SandboxProfileOverride string
|
||||
|
||||
// SandboxAllowOverrides holds runtime sandbox allow rules from --sandbox-allow flags.
|
||||
// These are additive rules applied on top of the resolved sandbox policy.
|
||||
// Not persisted to config.yml.
|
||||
SandboxAllowOverrides []SandboxAllowOverride
|
||||
|
||||
// Internal config values computed at runtime and must be accessed via. API
|
||||
configDir string
|
||||
configFilePath string
|
||||
@@ -161,6 +166,29 @@ func (r *RuntimeConfig) IsProxyModeEnabled() bool {
|
||||
return (r.Config.ExperimentalProxyMode || r.Config.ProxyMode)
|
||||
}
|
||||
|
||||
// SandboxAllowType represents the type of a sandbox allow override.
|
||||
type SandboxAllowType string
|
||||
|
||||
const (
|
||||
SandboxAllowRead SandboxAllowType = "read"
|
||||
SandboxAllowWrite SandboxAllowType = "write"
|
||||
SandboxAllowExec SandboxAllowType = "exec"
|
||||
SandboxAllowNetConnect SandboxAllowType = "net-connect"
|
||||
SandboxAllowNetBind SandboxAllowType = "net-bind"
|
||||
)
|
||||
|
||||
// SandboxAllowOverride represents a single --sandbox-allow flag value.
|
||||
type SandboxAllowOverride struct {
|
||||
// Type is the resource type (read, write, exec, net-connect, net-bind).
|
||||
Type SandboxAllowType
|
||||
|
||||
// Value is the resolved value (absolute path, host:port, etc.).
|
||||
Value string
|
||||
|
||||
// Raw is the original CLI value before resolution (for logging/warnings).
|
||||
Raw string
|
||||
}
|
||||
|
||||
// DefaultConfig is a fail safe contract for the runtime configuration.
|
||||
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
||||
func DefaultConfig() RuntimeConfig {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
)
|
||||
|
||||
// validSandboxAllowTypes is the set of recognized --sandbox-allow type prefixes.
|
||||
var validSandboxAllowTypes = map[SandboxAllowType]bool{
|
||||
SandboxAllowRead: true,
|
||||
SandboxAllowWrite: true,
|
||||
SandboxAllowExec: true,
|
||||
SandboxAllowNetConnect: true,
|
||||
SandboxAllowNetBind: true,
|
||||
}
|
||||
|
||||
// parseSandboxAllowOverrides parses raw --sandbox-allow flag values into validated overrides.
|
||||
// Each raw value must be in the format "type=value" (e.g., "write=./.gitignore").
|
||||
func parseSandboxAllowOverrides(raw []string) ([]SandboxAllowOverride, error) {
|
||||
overrides := make([]SandboxAllowOverride, 0, len(raw))
|
||||
|
||||
for _, r := range raw {
|
||||
override, err := parseSingleOverride(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --sandbox-allow %q: %w", r, err)
|
||||
}
|
||||
|
||||
overrides = append(overrides, override)
|
||||
}
|
||||
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
// parseSingleOverride parses and validates a single "type=value" string.
|
||||
func parseSingleOverride(raw string) (SandboxAllowOverride, error) {
|
||||
// Split on first '=' only to handle values containing '='
|
||||
idx := strings.IndexByte(raw, '=')
|
||||
if idx < 0 {
|
||||
return SandboxAllowOverride{}, fmt.Errorf("missing '=' separator, expected format: type=value (e.g., write=./file)")
|
||||
}
|
||||
|
||||
typStr := raw[:idx]
|
||||
value := raw[idx+1:]
|
||||
|
||||
if typStr == "" {
|
||||
return SandboxAllowOverride{}, fmt.Errorf("missing type before '=', expected format: type=value")
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return SandboxAllowOverride{}, fmt.Errorf("missing value after '=', expected format: type=value")
|
||||
}
|
||||
|
||||
allowType := SandboxAllowType(typStr)
|
||||
|
||||
// Provide a helpful error for the common mistake of using "net" instead of "net-connect"/"net-bind"
|
||||
if typStr == "net" {
|
||||
return SandboxAllowOverride{}, fmt.Errorf("unknown type %q (use net-connect or net-bind)", typStr)
|
||||
}
|
||||
|
||||
if !validSandboxAllowTypes[allowType] {
|
||||
return SandboxAllowOverride{}, fmt.Errorf("unknown type %q, valid types: read, write, exec, net-connect, net-bind", typStr)
|
||||
}
|
||||
|
||||
resolved, err := validateAndResolveValue(allowType, value)
|
||||
if err != nil {
|
||||
return SandboxAllowOverride{}, err
|
||||
}
|
||||
|
||||
return SandboxAllowOverride{
|
||||
Type: allowType,
|
||||
Value: resolved,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateAndResolveValue validates the value for the given type and resolves paths.
|
||||
func validateAndResolveValue(typ SandboxAllowType, value string) (string, error) {
|
||||
switch typ {
|
||||
case SandboxAllowRead, SandboxAllowWrite:
|
||||
return resolveFilesystemPath(value)
|
||||
case SandboxAllowExec:
|
||||
return resolveExecPath(value)
|
||||
case SandboxAllowNetConnect:
|
||||
return validateNetConnect(value)
|
||||
case SandboxAllowNetBind:
|
||||
return validateNetBind(value)
|
||||
default:
|
||||
return "", fmt.Errorf("unhandled type: %s", typ)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFilesystemPath resolves a filesystem path for read/write overrides.
|
||||
// Supports glob patterns. Resolves relative paths to absolute via CWD.
|
||||
func resolveFilesystemPath(value string) (string, error) {
|
||||
if err := checkTildePath(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resolveToAbsolute(value)
|
||||
}
|
||||
|
||||
// resolveExecPath resolves an exec path. Rejects glob patterns.
|
||||
func resolveExecPath(value string) (string, error) {
|
||||
if err := checkTildePath(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if containsGlob(value) {
|
||||
return "", fmt.Errorf("glob patterns are not allowed for exec type (specify exact path)")
|
||||
}
|
||||
|
||||
return resolveToAbsolute(value)
|
||||
}
|
||||
|
||||
// validateNetConnect validates a net-connect value (host:port format, no wildcards).
|
||||
func validateNetConnect(value string) (string, error) {
|
||||
host, port, err := parseHostPort(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid net-connect value: %w", err)
|
||||
}
|
||||
|
||||
if host == "*" || strings.Contains(host, "*") || strings.Contains(host, "?") {
|
||||
return "", fmt.Errorf("wildcards are not allowed for net-connect (specify exact host:port)")
|
||||
}
|
||||
|
||||
if port == "*" {
|
||||
return "", fmt.Errorf("port wildcard is not allowed for net-connect (specify exact host:port)")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// validateNetBind validates a net-bind value.
|
||||
// Allows localhost:* and 127.0.0.1:* as special wildcard forms.
|
||||
// Warns on non-localhost addresses.
|
||||
func validateNetBind(value string) (string, error) {
|
||||
host, _, err := parseHostPort(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid net-bind value: %w", err)
|
||||
}
|
||||
|
||||
// Reject host-side wildcards
|
||||
if host == "*" || strings.Contains(host, "*") || strings.Contains(host, "?") {
|
||||
return "", fmt.Errorf("host wildcards are not allowed for net-bind (specify a host, e.g., localhost:3000)")
|
||||
}
|
||||
|
||||
// Warn on non-localhost addresses
|
||||
if !isLocalhostAddress(host) {
|
||||
log.Warnf("--sandbox-allow net-bind=%s uses non-localhost address, this exposes the port to the network", value)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseHostPort parses a host:port string. The port may be "*" for wildcard.
|
||||
func parseHostPort(value string) (string, string, error) {
|
||||
// Find the last ':' to split host and port (handles IPv6 in the future)
|
||||
lastColon := strings.LastIndex(value, ":")
|
||||
if lastColon < 0 {
|
||||
return "", "", fmt.Errorf("expected host:port format (e.g., example.com:443), got %q", value)
|
||||
}
|
||||
|
||||
host := value[:lastColon]
|
||||
port := value[lastColon+1:]
|
||||
|
||||
if host == "" {
|
||||
return "", "", fmt.Errorf("missing host in host:port value %q", value)
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
return "", "", fmt.Errorf("missing port in host:port value %q", value)
|
||||
}
|
||||
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// isLocalhostAddress returns true if the host is a localhost address.
|
||||
func isLocalhostAddress(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
// resolveToAbsolute resolves a path to an absolute path relative to CWD.
|
||||
// Glob characters are preserved. The path is cleaned via filepath.Clean().
|
||||
func resolveToAbsolute(value string) (string, error) {
|
||||
if filepath.IsAbs(value) {
|
||||
return filepath.Clean(value), nil
|
||||
}
|
||||
|
||||
// For paths with glob characters, we need to preserve them through Clean.
|
||||
// filepath.Clean handles ".." and "." but leaves glob chars intact.
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Clean(filepath.Join(cwd, value)), nil
|
||||
}
|
||||
|
||||
// checkTildePath returns an error if the path starts with "~" (unexpanded tilde).
|
||||
func checkTildePath(value string) error {
|
||||
if strings.HasPrefix(value, "~") {
|
||||
return fmt.Errorf("path %q starts with '~' which was not expanded by your shell; use an absolute path instead", value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsGlob returns true if the pattern contains glob wildcards.
|
||||
func containsGlob(pattern string) bool {
|
||||
return strings.Contains(pattern, "*") ||
|
||||
strings.Contains(pattern, "?") ||
|
||||
strings.Contains(pattern, "[")
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSandboxAllowOverrides_ValidFormats(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
expectedType SandboxAllowType
|
||||
expectedValue string
|
||||
}{
|
||||
{
|
||||
name: "write with relative path",
|
||||
raw: "write=./.gitignore",
|
||||
expectedType: SandboxAllowWrite,
|
||||
expectedValue: filepath.Join(cwd, ".gitignore"),
|
||||
},
|
||||
{
|
||||
name: "write with absolute path",
|
||||
raw: "write=/tmp/output",
|
||||
expectedType: SandboxAllowWrite,
|
||||
expectedValue: "/tmp/output",
|
||||
},
|
||||
{
|
||||
name: "write with glob pattern",
|
||||
raw: "write=./dist/**",
|
||||
expectedType: SandboxAllowWrite,
|
||||
expectedValue: filepath.Join(cwd, "dist/**"),
|
||||
},
|
||||
{
|
||||
name: "read with absolute path",
|
||||
raw: "read=/opt/config/registry.json",
|
||||
expectedType: SandboxAllowRead,
|
||||
expectedValue: "/opt/config/registry.json",
|
||||
},
|
||||
{
|
||||
name: "read with glob pattern",
|
||||
raw: "read=./src/**",
|
||||
expectedType: SandboxAllowRead,
|
||||
expectedValue: filepath.Join(cwd, "src/**"),
|
||||
},
|
||||
{
|
||||
name: "exec with absolute path",
|
||||
raw: "exec=/usr/bin/curl",
|
||||
expectedType: SandboxAllowExec,
|
||||
expectedValue: "/usr/bin/curl",
|
||||
},
|
||||
{
|
||||
name: "net-connect with host:port",
|
||||
raw: "net-connect=registry.npmjs.org:443",
|
||||
expectedType: SandboxAllowNetConnect,
|
||||
expectedValue: "registry.npmjs.org:443",
|
||||
},
|
||||
{
|
||||
name: "net-bind with localhost",
|
||||
raw: "net-bind=127.0.0.1:3000",
|
||||
expectedType: SandboxAllowNetBind,
|
||||
expectedValue: "127.0.0.1:3000",
|
||||
},
|
||||
{
|
||||
name: "net-bind with localhost wildcard port",
|
||||
raw: "net-bind=localhost:*",
|
||||
expectedType: SandboxAllowNetBind,
|
||||
expectedValue: "localhost:*",
|
||||
},
|
||||
{
|
||||
name: "write with relative path no dot prefix",
|
||||
raw: "write=dist/output",
|
||||
expectedType: SandboxAllowWrite,
|
||||
expectedValue: filepath.Join(cwd, "dist/output"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
overrides, err := parseSandboxAllowOverrides([]string{tt.raw})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overrides, 1)
|
||||
|
||||
assert.Equal(t, tt.expectedType, overrides[0].Type)
|
||||
assert.Equal(t, tt.expectedValue, overrides[0].Value)
|
||||
assert.Equal(t, tt.raw, overrides[0].Raw)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_MultipleValues(t *testing.T) {
|
||||
raw := []string{
|
||||
"write=./.gitignore",
|
||||
"exec=/usr/bin/curl",
|
||||
"net-connect=example.com:443",
|
||||
}
|
||||
|
||||
overrides, err := parseSandboxAllowOverrides(raw)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overrides, 3)
|
||||
|
||||
assert.Equal(t, SandboxAllowWrite, overrides[0].Type)
|
||||
assert.Equal(t, SandboxAllowExec, overrides[1].Type)
|
||||
assert.Equal(t, SandboxAllowNetConnect, overrides[2].Type)
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_EmptySlice(t *testing.T) {
|
||||
overrides, err := parseSandboxAllowOverrides([]string{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, overrides)
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_InvalidFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "missing separator",
|
||||
raw: "./foo",
|
||||
errContains: "missing '=' separator",
|
||||
},
|
||||
{
|
||||
name: "missing type",
|
||||
raw: "=./foo",
|
||||
errContains: "missing type before '='",
|
||||
},
|
||||
{
|
||||
name: "empty value",
|
||||
raw: "write=",
|
||||
errContains: "missing value after '='",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
raw: "foo=bar",
|
||||
errContains: "unknown type",
|
||||
},
|
||||
{
|
||||
name: "net shorthand rejected",
|
||||
raw: "net=host:443",
|
||||
errContains: "use net-connect or net-bind",
|
||||
},
|
||||
{
|
||||
name: "exec with glob pattern",
|
||||
raw: "exec=/usr/bin/*",
|
||||
errContains: "glob patterns are not allowed for exec",
|
||||
},
|
||||
{
|
||||
name: "net-connect with wildcard host",
|
||||
raw: "net-connect=*:443",
|
||||
errContains: "wildcards are not allowed for net-connect",
|
||||
},
|
||||
{
|
||||
name: "net-connect with glob host",
|
||||
raw: "net-connect=*.example.com:443",
|
||||
errContains: "wildcards are not allowed for net-connect",
|
||||
},
|
||||
{
|
||||
name: "net-connect with wildcard port",
|
||||
raw: "net-connect=example.com:*",
|
||||
errContains: "port wildcard is not allowed for net-connect",
|
||||
},
|
||||
{
|
||||
name: "net-bind with host wildcard",
|
||||
raw: "net-bind=*:3000",
|
||||
errContains: "host wildcards are not allowed for net-bind",
|
||||
},
|
||||
{
|
||||
name: "net-bind with full wildcard",
|
||||
raw: "net-bind=*:*",
|
||||
errContains: "host wildcards are not allowed for net-bind",
|
||||
},
|
||||
{
|
||||
name: "net-connect missing port",
|
||||
raw: "net-connect=example.com",
|
||||
errContains: "expected host:port format",
|
||||
},
|
||||
{
|
||||
name: "net-bind missing port",
|
||||
raw: "net-bind=localhost",
|
||||
errContains: "expected host:port format",
|
||||
},
|
||||
{
|
||||
name: "tilde path for write",
|
||||
raw: "write=~/file",
|
||||
errContains: "starts with '~'",
|
||||
},
|
||||
{
|
||||
name: "tilde path for read",
|
||||
raw: "read=~/.config/foo",
|
||||
errContains: "starts with '~'",
|
||||
},
|
||||
{
|
||||
name: "tilde path for exec",
|
||||
raw: "exec=~/bin/tool",
|
||||
errContains: "starts with '~'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseSandboxAllowOverrides([]string{tt.raw})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_ValueWithEquals(t *testing.T) {
|
||||
// Values can contain '=' characters — the parser splits on the first '=' only
|
||||
overrides, err := parseSandboxAllowOverrides([]string{"write=./path=with=equals.txt"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overrides, 1)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, SandboxAllowWrite, overrides[0].Type)
|
||||
assert.Equal(t, filepath.Join(cwd, "path=with=equals.txt"), overrides[0].Value)
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_PathCleaning(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Paths with ".." are cleaned via filepath.Clean
|
||||
overrides, err := parseSandboxAllowOverrides([]string{"write=./foo/../bar"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overrides, 1)
|
||||
|
||||
assert.Equal(t, filepath.Join(cwd, "bar"), overrides[0].Value)
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_NetBindNonLocalhost(t *testing.T) {
|
||||
// Non-localhost should succeed (with a warning logged, which we can't easily assert here)
|
||||
overrides, err := parseSandboxAllowOverrides([]string{"net-bind=0.0.0.0:3000"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, overrides, 1)
|
||||
|
||||
assert.Equal(t, SandboxAllowNetBind, overrides[0].Type)
|
||||
assert.Equal(t, "0.0.0.0:3000", overrides[0].Value)
|
||||
}
|
||||
|
||||
func TestParseSandboxAllowOverrides_FirstErrorStops(t *testing.T) {
|
||||
// If the first value is invalid, the second is not parsed
|
||||
_, err := parseSandboxAllowOverrides([]string{"write=./ok", "bad"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing '=' separator")
|
||||
}
|
||||
+29
-1
@@ -54,6 +54,34 @@ Run sandbox with custom policy file:
|
||||
pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express
|
||||
```
|
||||
|
||||
### Runtime Allow Overrides
|
||||
|
||||
Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful when a command needs access that the default profile blocks.
|
||||
|
||||
```bash
|
||||
# Allow writing to a specific file
|
||||
pmg --sandbox-allow write=./.gitignore npx create-next-app@latest
|
||||
|
||||
# Allow executing a binary blocked by the profile
|
||||
pmg --sandbox-allow exec=$(which curl) npm install some-package
|
||||
|
||||
# Allow outbound connection to a private registry
|
||||
pmg --sandbox-allow net-connect=npm.internal.corp:443 npm install @corp/private-pkg
|
||||
|
||||
# Allow a dev server to bind to a local port
|
||||
pmg --sandbox-allow net-bind=127.0.0.1:3000 npx some-dev-tool
|
||||
|
||||
# Multiple overrides
|
||||
pmg \
|
||||
--sandbox-allow write=./.gitignore \
|
||||
--sandbox-allow exec=$(which curl) \
|
||||
npm install some-package
|
||||
```
|
||||
|
||||
Supported types: `read`, `write`, `exec`, `net-connect`, `net-bind`.
|
||||
|
||||
Overrides are additive (append to allow lists), non-persistent (apply to current invocation only), and logged in the event log for auditing. They cannot bypass explicit deny rules in the profile or mandatory security protections (`.env`, `.ssh`, `.aws`, `.git/hooks`, etc.).
|
||||
|
||||
<details>
|
||||
<summary>Custom policy overrides using Policy Templates</summary>
|
||||
|
||||
@@ -256,4 +284,4 @@ bwrap --verbose [arguments...] -- npm install express
|
||||
|
||||
- https://github.com/anthropic-experimental/sandbox-runtime
|
||||
- https://geminicli.com/docs/cli/sandbox/
|
||||
- https://github.com/containers/bubblewrap
|
||||
- https://github.com/containers/bubblewrap
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
EventTypeDependencyResolved EventType = "dependency_resolved"
|
||||
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
|
||||
EventTypeProxyHostObserved EventType = "proxy_host_observed"
|
||||
EventTypeSandboxOverride EventType = "sandbox_override"
|
||||
EventTypeError EventType = "error"
|
||||
)
|
||||
|
||||
@@ -413,6 +414,22 @@ func LogProxyHostObserved(hostname, method, reason string, details map[string]in
|
||||
}
|
||||
}
|
||||
|
||||
// LogSandboxOverrides logs when runtime sandbox allow overrides are applied.
|
||||
func LogSandboxOverrides(sandboxProfile string, overrides []map[string]string) {
|
||||
event := Event{
|
||||
EventType: EventTypeSandboxOverride,
|
||||
Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)),
|
||||
Details: map[string]interface{}{
|
||||
"sandbox_profile": sandboxProfile,
|
||||
"sandbox_runtime_overrides": overrides,
|
||||
},
|
||||
}
|
||||
|
||||
if err := LogEvent(event); err != nil {
|
||||
log.Warnf("failed to log sandbox override event: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error event
|
||||
func LogError(message string, err error) {
|
||||
event := Event{
|
||||
|
||||
@@ -72,6 +72,11 @@ func main() {
|
||||
if eventlogErr != nil {
|
||||
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
||||
}
|
||||
|
||||
// Parse and validate --sandbox-allow flags after all flags are resolved
|
||||
if err := config.FinalizeSandboxAllowOverrides(); err != nil {
|
||||
ui.Fatalf("pmg: %v", err)
|
||||
}
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/eventlog"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
"github.com/safedep/pmg/sandbox/platform"
|
||||
"github.com/safedep/pmg/usefulerror"
|
||||
@@ -114,6 +116,12 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
|
||||
|
||||
log.Debugf("Loaded sandbox policy %s", policy.Name)
|
||||
|
||||
// Apply runtime --sandbox-allow overrides to the policy before execution
|
||||
if len(cfg.SandboxAllowOverrides) > 0 {
|
||||
applyRuntimeOverrides(policy, cfg.SandboxAllowOverrides)
|
||||
logSandboxOverridesToEventLog(policy.Name, cfg.SandboxAllowOverrides)
|
||||
}
|
||||
|
||||
if !policy.AppliesToPackageManager(pmName) {
|
||||
return nil, fmt.Errorf("sandbox policy %s does not apply to %s", policy.Name, pmName)
|
||||
}
|
||||
@@ -146,3 +154,49 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyRuntimeOverrides applies --sandbox-allow overrides to the policy.
|
||||
// Overrides are additive — they only append to allow lists, never modify deny lists.
|
||||
// Warnings are logged for conflicts with deny rules and mandatory deny patterns.
|
||||
func applyRuntimeOverrides(policy *sandbox.SandboxPolicy, overrides []config.SandboxAllowOverride) {
|
||||
for _, override := range overrides {
|
||||
switch override.Type {
|
||||
case config.SandboxAllowRead:
|
||||
log.Infof("Sandbox override: allowing read access to %s", override.Value)
|
||||
policy.Filesystem.AllowRead = append(policy.Filesystem.AllowRead, override.Value)
|
||||
|
||||
case config.SandboxAllowWrite:
|
||||
log.Infof("Sandbox override: allowing write access to %s", override.Value)
|
||||
policy.Filesystem.AllowWrite = append(policy.Filesystem.AllowWrite, override.Value)
|
||||
|
||||
case config.SandboxAllowExec:
|
||||
log.Infof("Sandbox override: allowing execution of %s", override.Value)
|
||||
policy.Process.AllowExec = append(policy.Process.AllowExec, override.Value)
|
||||
|
||||
case config.SandboxAllowNetConnect:
|
||||
log.Infof("Sandbox override: allowing outbound connection to %s", override.Value)
|
||||
policy.Network.AllowOutbound = append(policy.Network.AllowOutbound, override.Value)
|
||||
|
||||
case config.SandboxAllowNetBind:
|
||||
log.Infof("Sandbox override: allowing network bind on %s", override.Value)
|
||||
policy.Network.AllowBind = append(policy.Network.AllowBind, override.Value)
|
||||
|
||||
// Enable AllowNetworkBind so the translator emits bind rules.
|
||||
// Without this, AllowBind entries would be ignored on some platforms.
|
||||
policy.AllowNetworkBind = utils.PtrTo(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logSandboxOverridesToEventLog records sandbox allow overrides in the audit event log.
|
||||
func logSandboxOverridesToEventLog(profileName string, overrides []config.SandboxAllowOverride) {
|
||||
entries := make([]map[string]string, 0, len(overrides))
|
||||
for _, o := range overrides {
|
||||
entries = append(entries, map[string]string{
|
||||
"type": string(o.Type),
|
||||
"value": o.Value,
|
||||
})
|
||||
}
|
||||
|
||||
eventlog.LogSandboxOverrides(profileName, entries)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplyRuntimeOverrides_Read(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowRead: []string{"/existing"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowRead, Value: "/new/path", Raw: "read=/new/path"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Filesystem.AllowRead, "/existing")
|
||||
assert.Contains(t, policy.Filesystem.AllowRead, "/new/path")
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_Write(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowWrite: []string{"/existing"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowWrite, Value: "/new/file", Raw: "write=/new/file"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Filesystem.AllowWrite, "/existing")
|
||||
assert.Contains(t, policy.Filesystem.AllowWrite, "/new/file")
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_Exec(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Process: sandbox.ProcessPolicy{
|
||||
AllowExec: []string{"/usr/bin/node"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/node")
|
||||
assert.Contains(t, policy.Process.AllowExec, "/usr/bin/curl")
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_NetConnect(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Network: sandbox.NetworkPolicy{
|
||||
AllowOutbound: []string{"registry.npmjs.org:443"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Network.AllowOutbound, "registry.npmjs.org:443")
|
||||
assert.Contains(t, policy.Network.AllowOutbound, "example.com:443")
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_NetBind(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Network: sandbox.NetworkPolicy{
|
||||
AllowBind: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000")
|
||||
assert.NotNil(t, policy.AllowNetworkBind)
|
||||
assert.True(t, *policy.AllowNetworkBind)
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_NetBindPreservesExistingTrue(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
AllowNetworkBind: utils.PtrTo(true),
|
||||
Network: sandbox.NetworkPolicy{
|
||||
AllowBind: []string{"localhost:8080"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowNetBind, Value: "127.0.0.1:3000", Raw: "net-bind=127.0.0.1:3000"},
|
||||
})
|
||||
|
||||
assert.Contains(t, policy.Network.AllowBind, "localhost:8080")
|
||||
assert.Contains(t, policy.Network.AllowBind, "127.0.0.1:3000")
|
||||
assert.True(t, *policy.AllowNetworkBind)
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_MultipleOverrides(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Filesystem: sandbox.FilesystemPolicy{},
|
||||
Process: sandbox.ProcessPolicy{},
|
||||
Network: sandbox.NetworkPolicy{},
|
||||
}
|
||||
|
||||
overrides := []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowWrite, Value: "/path/a", Raw: "write=/path/a"},
|
||||
{Type: config.SandboxAllowWrite, Value: "/path/b", Raw: "write=/path/b"},
|
||||
{Type: config.SandboxAllowExec, Value: "/usr/bin/curl", Raw: "exec=/usr/bin/curl"},
|
||||
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, overrides)
|
||||
|
||||
assert.Len(t, policy.Filesystem.AllowWrite, 2)
|
||||
assert.Len(t, policy.Process.AllowExec, 1)
|
||||
assert.Len(t, policy.Network.AllowOutbound, 1)
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_EmptyOverrides(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowWrite: []string{"/existing"},
|
||||
},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, []config.SandboxAllowOverride{})
|
||||
|
||||
// Policy should be unchanged
|
||||
assert.Equal(t, []string{"/existing"}, policy.Filesystem.AllowWrite)
|
||||
}
|
||||
|
||||
func TestApplyRuntimeOverrides_DenyListsUnmodified(t *testing.T) {
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
DenyWrite: []string{"/protected"},
|
||||
},
|
||||
Process: sandbox.ProcessPolicy{
|
||||
DenyExec: []string{"/usr/bin/curl"},
|
||||
},
|
||||
Network: sandbox.NetworkPolicy{
|
||||
DenyOutbound: []string{"*:*"},
|
||||
},
|
||||
}
|
||||
|
||||
overrides := []config.SandboxAllowOverride{
|
||||
{Type: config.SandboxAllowWrite, Value: "/something", Raw: "write=/something"},
|
||||
{Type: config.SandboxAllowExec, Value: "/usr/bin/wget", Raw: "exec=/usr/bin/wget"},
|
||||
{Type: config.SandboxAllowNetConnect, Value: "example.com:443", Raw: "net-connect=example.com:443"},
|
||||
}
|
||||
|
||||
applyRuntimeOverrides(policy, overrides)
|
||||
|
||||
// Deny lists should never be modified by overrides
|
||||
assert.Equal(t, []string{"/protected"}, policy.Filesystem.DenyWrite)
|
||||
assert.Equal(t, []string{"/usr/bin/curl"}, policy.Process.DenyExec)
|
||||
assert.Equal(t, []string{"*:*"}, policy.Network.DenyOutbound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user