diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 82db595..24b81f9 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -442,7 +442,7 @@ jobs: touch ~/.ssh/id_rsa - name: Run Sandbox E2E Test - run: pmg --sandbox npm exec -- node test/sandbox-e2e.js + run: pmg --sandbox --sandbox-enforce npm exec -- node test/sandbox-e2e.js sandbox-e2e-linux: name: Sandbox E2E - Linux (Bubblewrap) @@ -500,4 +500,4 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Run Sandbox E2E Test - run: pmg --sandbox --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js + run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js diff --git a/config/cobra.go b/config/cobra.go index 7f4113b..67a2500 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -23,6 +23,8 @@ func ApplyCobraFlags(cmd *cobra.Command) { globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)") cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox", globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)") + cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, "sandbox-enforce", + 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)") } diff --git a/config/config.go b/config/config.go index 020bf1a..97b2f25 100644 --- a/config/config.go +++ b/config/config.go @@ -70,6 +70,11 @@ type SandboxConfig struct { // Enabled enables sandbox mode (opt-in by default for backward compatibility). Enabled bool `mapstructure:"enabled"` + // EnforceAlways controls scope of sandbox enforcement: + // - When true: sandbox applies to all package manager commands + // - When false: sandbox only applies to install commands, others run unrestricted (default) + EnforceAlways bool `mapstructure:"enforce_always"` + // Policies maps package manager names to their sandbox policy references. // Key is package manager name (e.g., "npm", "pip"), value is policy reference. Policies map[string]SandboxPolicyRef `mapstructure:"policies"` @@ -168,7 +173,8 @@ func DefaultConfig() RuntimeConfig { ExperimentalProxyMode: false, TrustedPackages: []TrustedPackage{}, Sandbox: SandboxConfig{ - Enabled: false, + Enabled: false, + EnforceAlways: false, }, }, DryRun: false, @@ -284,6 +290,14 @@ func Get() *RuntimeConfig { return globalConfig } +func ConfigureSandbox(isInstallationCommand bool) { + if globalConfig.Config.Sandbox.Enabled { + // Apply sandbox to all commands if EnforceAlways=true, otherwise only to + // installation commands else disable the sandbox + globalConfig.Config.Sandbox.Enabled = globalConfig.Config.Sandbox.EnforceAlways || isInstallationCommand + } +} + // WriteTemplateConfig writes the template configuration file to disk if it doesn't already exist. func WriteTemplateConfig() error { configDir, err := configDir() diff --git a/config/config.template.yml b/config/config.template.yml index 4d18730..c167ffe 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -69,6 +69,12 @@ sandbox: # Enable sandbox mode (opt-in, default: false for backward compatibility) enabled: false + # Controls scope of sandbox enforcement: + # - true: sandbox applies to all package manager commands + # - false (default): sandbox only applies to install commands, others run unrestricted + # Requires 'enabled: true' and per-PM policies to be active. May break workflows expecting unrestricted commands. + enforce_always: false + # Policy templates define policy profiles by name and path. # They can be used to override a built-in profile or create a custom profile. # Note: Custom profiles loaded via policy_templates can inherit from built-in @@ -87,7 +93,7 @@ sandbox: # npm ecosystem. npm-restrictive is a built-in profile. npm: enabled: true - profile: npm-restrictive # Built-in profile, template name, or path to custom YAML + profile: npm-restrictive # Built-in profile, template name, or path to custom YAML pnpm: enabled: true @@ -125,4 +131,3 @@ sandbox: uv: enabled: true profile: pypi-restrictive - diff --git a/internal/flows/common_flow.go b/internal/flows/common_flow.go index ddbf36c..119c3aa 100644 --- a/internal/flows/common_flow.go +++ b/internal/flows/common_flow.go @@ -28,9 +28,13 @@ func Common(pm packagemanager.PackageManager, pkgResolver packagemanager.Package func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error { var analyzers []analyzer.PackageVersionAnalyzer - config := config.Get() - if config.Config.Paranoid { + // Configure sandbox based on command type and enforcement policy + config.ConfigureSandbox(parsedCmd.IsInstallationCommand()) + + cfg := config.Get() + + if cfg.Config.Paranoid { malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig()) if err != nil { return fmt.Errorf("failed to create malware analyzer: %s", err) @@ -55,15 +59,15 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem } guardConfig := guard.DefaultPackageManagerGuardConfig() - guardConfig.DryRun = config.DryRun - guardConfig.InsecureInstallation = config.InsecureInstallation + guardConfig.DryRun = cfg.DryRun + guardConfig.InsecureInstallation = cfg.InsecureInstallation - proxy, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) + guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) if err != nil { return fmt.Errorf("failed to create package manager guard: %s", err) } - err = proxy.Run(ctx, args, parsedCmd) + err = guardManager.Run(ctx, args, parsedCmd) if err != nil { return fmt.Errorf("failed to run package manager guard: %w", err) } diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 71e6148..32c4068 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -48,6 +48,9 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String()) } + // Configure sandbox based on command type and enforcement policy + config.ConfigureSandbox(parsedCmd.IsInstallationCommand()) + cfg := config.Get() // Check if dry-run mode is enabled diff --git a/packagemanager/packagemanager.go b/packagemanager/packagemanager.go index 5215420..56c38b6 100644 --- a/packagemanager/packagemanager.go +++ b/packagemanager/packagemanager.go @@ -40,6 +40,11 @@ type ParsedCommand struct { ManifestFiles []string } +// IsInstallationCommand returns true if command installs packages (explicit targets or from manifest). +func (pc *ParsedCommand) IsInstallationCommand() bool { + return pc.HasInstallTarget() || pc.HasManifestInstall() +} + func (pc *ParsedCommand) HasInstallTarget() bool { return len(pc.InstallTargets) > 0 }