From 365deb18971e41dab8737b7bbd7ad5c9b02fb78a Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Fri, 17 Apr 2026 01:13:30 +0530 Subject: [PATCH] feat: Add proxy_install_only config to restrict proxy to download commands (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add proxy_install_only config to restrict proxy to download commands Introduces proxy_install_only (default: false) which, when enabled, skips the proxy for package manager commands that do not download packages (e.g. npm ls, pip list), avoiding unnecessary MITM overhead. - Add ProxyInstallOnly to Config and config template - Add IsKnownDownloadCommand / MayDownloadPackages to ParsedCommand - Add DownloadCommands to npm and pypi PM configs covering update, ci, audit, dlx, exec, x, download, run and equivalents per PM - Extract shared runner.Execute used by both proxy flow and guard - Proxy flow short-circuits to runner.Execute for non-download commands when proxy_install_only=true * refactor: Inject CommandExecutor into guard to fix dependency direction guard depended on internal/runner, which inverted the intended layer hierarchy. Now guard defines a CommandExecutor function type and accepts it as a constructor argument. internal/flows (the composition root) creates the executor closure wrapping runner.Execute and injects it, keeping guard free of internal/ dependencies. * refactor: Invert proxy_install_only logic to use known non-download commands Replace the DownloadCommands allowlist (opt-in, fail-open) with a NonDownloadCommands denylist (opt-out, fail-safe). The proxy now runs for all commands except those explicitly known to not download packages. Unknown or future package manager subcommands default to running with the proxy. Includes script runners (run, start, test, stop, restart) that can spin up local servers — setting proxy env vars on these breaks them without providing any security benefit. Also covers removal commands and local operations that never contact the registry. * fix: Support PMG_* env vars regardless of config file state AutomaticEnv only resolves env vars for keys Viper already knows about via AllKeys(). When a key is absent from the config file (commented out, new key added after last setup, or no config file at all), Viper had no knowledge of it and silently skipped the env var. Fix by registering all Config struct fields as Viper defaults via reflection (using mapstructure tags) before reading the config file. This ensures PMG_* env vars work in all cases. Precedence: cobra flags > env vars > config file > defaults. SetDefault is used (not Set) so env vars and config file can still override the Go defaults freely. Tests added covering all precedence levels including the key-absent- from-config-file case that was the original bug report. * fix: Only check first non-flag arg against NonDownloadCommands Scanning all args caused false proxy bypasses when package names or script arguments matched a NonDownloadCommands entry. For example: - npm exec test → "test" matched, proxy incorrectly skipped - npm update config → "config" matched, proxy skipped - npm publish --tag version → "version" matched, proxy skipped Fix by checking only the first non-flag argument (the subcommand). If it is not in NonDownloadCommands we break immediately, so trailing args never influence the classification. Applied to all four parsers: npm, pip/pip3, uv, and poetry. Regression tests added for the false positive cases. * refactor: Replace reflection-based Viper defaults with embedded template Load the embedded config template as the Viper base so all keys are registered upfront, enabling PMG_* env vars to work regardless of whether a key exists in the user's config file. * fix: Restore trusted_packages template entry and revert DefaultConfig change * docs: Document environment variable overrides for config keys * update npm test cmd * refactor: extract shared non-download command detection helper Replaces duplicated first-non-flag-arg detection loops in npm.go and pypi.go (pip + poetry parsers) with a shared isFirstNonFlagArgInList helper in packagemanager.go. https://claude.ai/code/session_01AHaKF3vc2Haj9tK3jgUBAs --------- Co-authored-by: Claude --- README.md | 2 +- config/config.go | 5 + config/config.template.yml | 6 + config/config_test.go | 107 +++++++++++++++- config/viper.go | 38 +++--- docs/config.md | 37 +++++- guard/guard.go | 58 ++------- guard/guard_test.go | 16 ++- internal/flows/common_flow.go | 8 +- internal/flows/proxy_flow.go | 7 ++ internal/runner/execute.go | 59 +++++++++ packagemanager/npm.go | 53 ++++++-- packagemanager/npm_test.go | 207 ++++++++++++++++++++++++++++++- packagemanager/packagemanager.go | 29 +++++ packagemanager/pypi.go | 50 ++++++-- packagemanager/pypi_test.go | 112 +++++++++++++++++ 16 files changed, 693 insertions(+), 101 deletions(-) create mode 100644 internal/runner/execute.go diff --git a/README.md b/README.md index a2dd02c..01321d3 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ pip install requests Verify PMG is working by installing a test package. This is a harmless package flagged as malicious in the SafeDep database, specifically meant for testing: ```bash -npm i safedep-test-pkg@0.1.3 +npm --prefer-online --no-cache i safedep-test-pkg@0.1.3 ```
diff --git a/config/config.go b/config/config.go index 650ca53..a8b9b3c 100644 --- a/config/config.go +++ b/config/config.go @@ -70,6 +70,11 @@ type Config struct { // we initially introduced it as an experimental feature. ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"` + // ProxyInstallOnly restricts proxy interception to install commands only. + // When false (default), proxy runs for all package manager commands. + // When true, non-install commands (e.g., npm ls, pip list) bypass the proxy and execute directly. + ProxyInstallOnly bool `mapstructure:"proxy_install_only"` + // Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose". Verbosity string `mapstructure:"verbosity"` diff --git a/config/config.template.yml b/config/config.template.yml index 86cfe0d..763baf9 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -36,6 +36,12 @@ event_log_retention_days: 7 # and can be disabled to fall back to the guard-based analysis. proxy_mode: true +# Restrict proxy to install commands only. Default is false. +# When false, the proxy intercepts all package manager commands (e.g., npm install, npm ls). +# When true, non-install commands bypass the proxy and execute directly, which can improve +# performance for commands that don't download packages (e.g., npm ls, pip list, npm outdated). +proxy_install_only: false + # Trusted packages are packages that are trusted by the user and will be ignored by the security guardrails. # This is useful for packages that are known to be safe and are used in the application. # Example: diff --git a/config/config_test.go b/config/config_test.go index 791404f..97f0320 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,9 +25,10 @@ func TestConfigHasDefaultValues(t *testing.T) { assert.Equal(t, 5, config.Config.TransitiveDepth) assert.Equal(t, false, config.Config.IncludeDevDependencies) assert.Equal(t, false, config.Config.Paranoid) - assert.Equal(t, []TrustedPackage{}, config.Config.TrustedPackages) + assert.Len(t, config.Config.TrustedPackages, 1) assert.Equal(t, "/tmp/pmg-test/random-does-not-exist", config.configDir) assert.Equal(t, "/tmp/pmg-test/random-does-not-exist/config.yml", config.configFilePath) + assert.Equal(t, false, config.Config.ProxyInstallOnly) }) t.Run("when no config directory is set", func(t *testing.T) { @@ -103,6 +105,109 @@ func TestPartialConfigWithNestedOverride(t *testing.T) { assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode) } +func TestProxyInstallOnlyConfig(t *testing.T) { + t.Run("defaults to false", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + initConfig() + assert.Equal(t, false, Get().Config.ProxyInstallOnly) + }) + + t.Run("can be set to true via config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly) + }) +} + +// TestConfigPrecedence verifies the expected override order: +// flags > env var > config file > default +func TestConfigPrecedence(t *testing.T) { + t.Run("env var overrides config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_install_only: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should override config file") + }) + + t.Run("config file overrides default", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "config file should override default") + }) + + t.Run("env var works when key is absent from config file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + // Config file exists but proxy_install_only is not in it (e.g. commented out + // or user hasn't re-run "pmg setup install" after an upgrade) + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even when key is absent from config file") + }) + + t.Run("env var works without a config file", func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist") + t.Setenv("PMG_PROXY_INSTALL_ONLY", "true") + + initConfig() + assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even without a config file") + }) + + t.Run("default is used when neither env var nor config file sets the key", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PROXY_INSTALL_ONLY", "") + + configPath := filepath.Join(tmpDir, "config.yml") + err := os.WriteFile(configPath, []byte("transitive: false\n"), 0o644) + require.NoError(t, err) + + initConfig() + assert.Equal(t, false, Get().Config.ProxyInstallOnly, "should use default when key absent from config and env") + }) + + t.Run("cobra flag overrides env var", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", tmpDir) + t.Setenv("PMG_PARANOID", "true") + + initConfig() + assert.Equal(t, true, Get().Config.Paranoid, "env var should set paranoid=true") + + // Simulate cobra flag parsing — BoolVar writes directly to the struct + // field after Viper, giving flags the highest effective precedence. + cmd := &cobra.Command{} + ApplyCobraFlags(cmd) + require.NoError(t, cmd.ParseFlags([]string{"--paranoid=false"})) + + assert.Equal(t, false, Get().Config.Paranoid, "cobra flag should override env var") + }) +} + func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) diff --git a/config/viper.go b/config/viper.go index 12aa026..a96310c 100644 --- a/config/viper.go +++ b/config/viper.go @@ -8,42 +8,36 @@ import ( "github.com/spf13/viper" ) -// loadViperConfig loads the configuration using Viper if available. -// It returns an error if the config file exists but cannot be read or parsed, -// allowing the caller to fall back to default configuration. +// loadViperConfig loads the configuration using Viper. +// Precedence (highest to lowest): cobra flags > env vars > config file > defaults. +// Cobra flags write directly to the config struct after this function runs. func loadViperConfig() error { configPath, err := configFilePath() if err != nil { return fmt.Errorf("failed to get config file path: %w", err) } - // Check if config file exists before attempting to load - // If it doesn't exist, we use the default configuration (see config.go) - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil - } - v := viper.New() - - v.SetConfigFile(configPath) v.SetConfigType("yaml") v.SetEnvPrefix("PMG") v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - if err := v.ReadInConfig(); err != nil { - return fmt.Errorf("failed to read config file %s: %w", configPath, err) + // Load the embedded template as the base so Viper knows all keys and their + // defaults. This is required for AutomaticEnv to resolve PMG_* env vars for + // keys that are absent from or newer than the user's config file. + if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil { + return fmt.Errorf("failed to load default config: %w", err) + } + + // Merge user config on top if it exists. + if _, statErr := os.Stat(configPath); statErr == nil { + v.SetConfigFile(configPath) + if err := v.MergeInConfig(); err != nil { + return fmt.Errorf("failed to read config file %s: %w", configPath, err) + } } - // Unmarshal into a copy of the current defaults (not a zero-value struct) so that - // keys missing from the YAML retain their defaults. This is critical for users who - // upgrade PMG without re-running "pmg setup install" — their old config.yml won't have - // newer keys (e.g. dependency_cooldown), and those must fall back to defaults rather than - // silently becoming Go zero values (false/0/""). - // - // We use a copy rather than unmarshalling directly into globalConfig.Config so that - // on error the caller's "using defaults" fallback is truthful — globalConfig.Config - // stays in a clean default state instead of being partially overwritten. merged := globalConfig.Config if err := v.Unmarshal(&merged); err != nil { return fmt.Errorf("failed to unmarshal config: %w", err) diff --git a/docs/config.md b/docs/config.md index 74c4a3b..77b7df7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,4 +12,39 @@ To see the configuration file path and activated configuration, run: pmg setup info ``` -See [config template](../config/config.template.yml) for the configuration schema. \ No newline at end of file +See [config template](../config/config.template.yml) for the configuration schema. + +## Environment Variables + +Any configuration key can be overridden using environment variables, without modifying the config file. This is useful for CI/CD pipelines or temporary overrides. + +**Format:** `PMG_` where the key is the config key uppercased, with nested keys joined by `_`. + +| Config key | Environment variable | +|---|---| +| `transitive` | `PMG_TRANSITIVE` | +| `paranoid` | `PMG_PARANOID` | +| `proxy_mode` | `PMG_PROXY_MODE` | +| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` | +| `verbosity` | `PMG_VERBOSITY` | +| `skip_event_logging` | `PMG_SKIP_EVENT_LOGGING` | +| `sandbox.enabled` | `PMG_SANDBOX_ENABLED` | +| `dependency_cooldown.enabled` | `PMG_DEPENDENCY_COOLDOWN_ENABLED` | +| `cloud.enabled` | `PMG_CLOUD_ENABLED` | + +**Example:** + +```bash +# Enable paranoid mode without editing the config file +PMG_PARANOID=true pmg npm install express + +# Restrict proxy to install commands only +PMG_PROXY_INSTALL_ONLY=true pmg npm install express +``` + +**Precedence (highest to lowest):** + +1. CLI flags +2. Environment variables (`PMG_*`) +3. Config file (`config.yml`) +4. Built-in defaults \ No newline at end of file diff --git a/guard/guard.go b/guard/guard.go index 3f19333..c676ac3 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "os/exec" "slices" "sync" "time" @@ -18,10 +17,13 @@ import ( "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" - "github.com/safedep/pmg/sandbox/executor" - "github.com/safedep/pmg/usefulerror" ) +// CommandExecutor executes a parsed package manager command directly. +// It is injected into the guard so that callers control execution behavior +// (e.g., dry-run, sandbox application) without guard depending on internal packages. +type CommandExecutor func(ctx context.Context, pc *packagemanager.ParsedCommand) error + type PackageManagerGuardInteraction struct { // SetStatus is called to set the status of the guard in the UI SetStatus func(status string) @@ -98,6 +100,7 @@ type packageManagerGuard struct { analyzers []analyzer.PackageVersionAnalyzer packageManager packagemanager.PackageManager packageResolver packagemanager.PackageResolver + executor CommandExecutor } func NewPackageManagerGuard(config PackageManagerGuardConfig, @@ -105,6 +108,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig, packageResolver packagemanager.PackageResolver, analyzers []analyzer.PackageVersionAnalyzer, interaction PackageManagerGuardInteraction, + executor CommandExecutor, ) (*packageManagerGuard, error) { return &packageManagerGuard{ interaction: interaction, @@ -112,6 +116,7 @@ func NewPackageManagerGuard(config PackageManagerGuardConfig, packageManager: packageManager, packageResolver: packageResolver, config: config, + executor: executor, }, nil } @@ -254,52 +259,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm } func (g *packageManagerGuard) continueExecution(ctx context.Context, pc *packagemanager.ParsedCommand) error { - if len(pc.Command.Exe) == 0 { - return fmt.Errorf("no command to execute") - } - - if g.config.DryRun { - log.Debugf("Dry run, skipping command execution") - return nil - } - - cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - pmName := g.packageManager.Name() - result, err := executor.ApplySandbox(ctx, cmd, pmName) - if err != nil { - return fmt.Errorf("failed to apply sandbox: %w", err) - } - - defer func() { - err := result.Close() - if err != nil { - log.Errorf("failed to close sandbox: %v", err) - } - }() - - if result.ShouldRun() { - err := cmd.Run() - if err != nil { - humanError := "Failed to execute package manager command" - if exitErr, ok := err.(*exec.ExitError); ok { - humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode()) - } - - return usefulerror.Useful(). - WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). - WithHumanError(humanError). - WithHelp("Check the package manager command and its arguments"). - Wrap(err) - } - - return nil - } - - return nil + return g.executor(ctx, pc) } func (g *packageManagerGuard) concurrentAnalyzePackages(ctx context.Context, diff --git a/guard/guard_test.go b/guard/guard_test.go index 09aa467..1b8ec50 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -11,6 +11,12 @@ import ( "github.com/stretchr/testify/assert" ) +// noopExecutor is a no-op executor for use in tests that set DryRun=true +// or otherwise don't reach actual command execution. +var noopExecutor CommandExecutor = func(_ context.Context, _ *packagemanager.ParsedCommand) error { + return nil +} + func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { mq, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) if err != nil { @@ -20,7 +26,7 @@ func TestGuardConcurrentlyAnalyzePackagesMalwareQueryService(t *testing.T) { pg, err := NewPackageManagerGuard(DefaultPackageManagerGuardConfig(), nil, nil, []analyzer.PackageVersionAnalyzer{mq}, PackageManagerGuardInteraction{ ShowWarning: func(message string) {}, - }) + }, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -80,7 +86,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -129,7 +135,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -184,7 +190,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } @@ -225,7 +231,7 @@ func TestGuardInsecureInstallation(t *testing.T) { } pg, err := NewPackageManagerGuard(config, nil, nil, - []analyzer.PackageVersionAnalyzer{mq}, interaction) + []analyzer.PackageVersionAnalyzer{mq}, interaction, noopExecutor) if err != nil { t.Fatalf("failed to create pg: %v", err) } diff --git a/internal/flows/common_flow.go b/internal/flows/common_flow.go index 153261d..df2f702 100644 --- a/internal/flows/common_flow.go +++ b/internal/flows/common_flow.go @@ -10,6 +10,7 @@ import ( "github.com/safedep/pmg/config" "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/runner" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" ) @@ -77,7 +78,12 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem guardConfig.DryRun = cfg.DryRun guardConfig.InsecureInstallation = cfg.InsecureInstallation - guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) + pmName := f.pm.Name() + executor := func(ctx context.Context, pc *packagemanager.ParsedCommand) error { + return runner.Execute(ctx, pc, pmName, cfg.DryRun) + } + + guardManager, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction, executor) if err != nil { return fmt.Errorf("failed to create package manager guard: %s", err) } diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 3030fbc..9f597ec 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -16,6 +16,7 @@ import ( "github.com/safedep/pmg/guard" "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/pty" + "github.com/safedep/pmg/internal/runner" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/proxy" @@ -51,6 +52,12 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema cfg := config.Get() + // Skip proxy for commands that don't download packages when proxy_install_only is enabled + if cfg.Config.ProxyInstallOnly && !parsedCmd.MayDownloadPackages() { + log.Debugf("Skipping proxy for non-download command (proxy_install_only=true)") + return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun) + } + // Initialize report data at the start reportData := ui.NewReportData() reportData.PackageManagerName = f.pm.Name() diff --git a/internal/runner/execute.go b/internal/runner/execute.go new file mode 100644 index 0000000..06a5b03 --- /dev/null +++ b/internal/runner/execute.go @@ -0,0 +1,59 @@ +package runner + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/packagemanager" + "github.com/safedep/pmg/sandbox/executor" + "github.com/safedep/pmg/usefulerror" +) + +// Execute runs a package manager command without proxy or guard analysis. +// It applies sandbox policy if configured, then executes the command directly. +func Execute(ctx context.Context, pc *packagemanager.ParsedCommand, pmName string, dryRun bool) error { + if len(pc.Command.Exe) == 0 { + return fmt.Errorf("no command to execute") + } + + if dryRun { + log.Debugf("Dry run, skipping command execution") + return nil + } + + cmd := exec.CommandContext(ctx, pc.Command.Exe, pc.Command.Args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + result, err := executor.ApplySandbox(ctx, cmd, pmName) + if err != nil { + return fmt.Errorf("failed to apply sandbox: %w", err) + } + + defer func() { + if err := result.Close(); err != nil { + log.Errorf("failed to close sandbox: %v", err) + } + }() + + if result.ShouldRun() { + if err := cmd.Run(); err != nil { + humanError := "Failed to execute package manager command" + if exitErr, ok := err.(*exec.ExitError); ok { + humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode()) + } + + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). + WithHumanError(humanError). + WithHelp("Check the package manager command and its arguments"). + Wrap(err) + } + } + + return nil +} diff --git a/packagemanager/npm.go b/packagemanager/npm.go index 302ed6d..b69e749 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -11,35 +11,73 @@ import ( ) type NpmPackageManagerConfig struct { - InstallCommands []string - CommandName string + InstallCommands []string + NonDownloadCommands []string + CommandName string } func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig { return NpmPackageManagerConfig{ InstallCommands: []string{"install", "i", "add"}, - CommandName: "npm", + // Commands that are known to never download packages from a registry. + // Anything not in this list (including unknown future commands) runs with the proxy. + // + // Script runners: "run", "start", "stop", "restart", "test"/"t" are all shorthand for + // "npm run