feat: Add proxy_install_only config to restrict proxy to download commands (#222)

* 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 <noreply@anthropic.com>
This commit is contained in:
Sahil Bansal
2026-04-17 01:13:30 +05:30
committed by GitHub
co-authored by Claude
parent 887984612c
commit 365deb1897
16 changed files with 693 additions and 101 deletions
+1 -1
View File
@@ -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
```
<details>
+5
View File
@@ -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"`
+6
View File
@@ -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:
+106 -1
View File
@@ -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)
+16 -22
View File
@@ -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)
+35
View File
@@ -13,3 +13,38 @@ pmg setup info
```
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_<KEY>` 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
+9 -49
View File
@@ -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,
+11 -5
View File
@@ -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)
}
+7 -1
View File
@@ -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)
}
+7
View File
@@ -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()
+59
View File
@@ -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
}
+45 -8
View File
@@ -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 <script>". They spin up local processes (dev servers, test runners) that make
// their own HTTP calls — setting proxy env vars breaks them without providing any security
// benefit since they don't contact the package registry themselves.
//
// "exec" is intentionally excluded — it downloads and runs a package (npx equivalent).
NonDownloadCommands: []string{
// Script runners — may start servers or long-running processes
"run", "start", "stop", "restart", "test", "t",
// Removal — uninstalls local packages, no registry download
"uninstall", "remove", "rm", "r", "un", "unlink",
// Local operations — no registry contact
"rebuild", "prune", "link", "cache", "pack",
// Inspection / read-only registry queries
"ls", "list", "outdated", "view", "info", "show", "search",
"config", "ping", "whoami", "version", "help",
},
CommandName: "npm",
}
}
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "i", "add"},
CommandName: "pnpm",
NonDownloadCommands: []string{
"run", "start", "stop", "restart", "test",
"remove", "rm", "uninstall", "un",
"prune", "link", "unlink",
"ls", "list", "outdated", "info", "view", "config", "why",
},
CommandName: "pnpm",
}
}
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "i", "add"},
CommandName: "bun",
NonDownloadCommands: []string{
// Script runners and local operations
"run", "test", "build",
// Removal
"remove", "rm",
},
CommandName: "bun",
}
}
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "add", ""},
CommandName: "yarn",
NonDownloadCommands: []string{
"run", "start", "stop", "restart", "test",
"remove", "unlink",
"ls", "list", "outdated", "info", "config", "why",
},
CommandName: "yarn",
}
}
@@ -96,8 +134,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}
if installCmdIndex == -1 {
// No install command found, return as-is
return &ParsedCommand{Command: command}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command
+205 -2
View File
@@ -71,12 +71,53 @@ func TestNpmParseCommand(t *testing.T) {
},
},
{
name: "not an installation command",
name: "update is not a known non-download command (proxy runs)",
command: "npm update @types/node",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.NotNil(t, parsedCommand)
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
assert.Empty(t, parsedCommand.InstallTargets)
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
assert.True(t, parsedCommand.MayDownloadPackages())
},
},
{
name: "ci is not a known non-download command (proxy runs)",
command: "npm ci",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
assert.True(t, parsedCommand.MayDownloadPackages())
assert.False(t, parsedCommand.IsInstallationCommand())
},
},
{
name: "audit is not a known non-download command (proxy runs; audit fix can download)",
command: "npm audit",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
assert.True(t, parsedCommand.MayDownloadPackages())
},
},
{
name: "ls is a known non-download command (proxy skipped)",
command: "npm ls",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.True(t, parsedCommand.IsKnownNonDownloadCommand)
assert.False(t, parsedCommand.MayDownloadPackages())
assert.False(t, parsedCommand.IsInstallationCommand())
},
},
{
name: "install sets MayDownloadPackages via IsInstallationCommand",
command: "npm install express",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.False(t, parsedCommand.IsKnownNonDownloadCommand)
assert.True(t, parsedCommand.IsInstallationCommand())
assert.True(t, parsedCommand.MayDownloadPackages())
},
},
{
@@ -403,3 +444,165 @@ func TestBunParseCommand(t *testing.T) {
})
}
}
func TestNpmProxyBehavior(t *testing.T) {
cases := []struct {
name string
pm func() (*npmPackageManager, error)
command string
isKnownNonDownloadCmd bool // proxy skipped when proxy_install_only=true
isInstallationCommand bool
}{
// Commands that proxy MUST run for (not known-safe)
{
name: "yarn upgrade — proxy runs (may download)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
command: "yarn upgrade",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "pnpm update — proxy runs (may download)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
command: "pnpm update",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "bun update — proxy runs (may download)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
command: "bun update",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "npm exec — proxy runs (may download and run package)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm exec create-react-app",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "pnpm dlx — proxy runs (downloads and runs package)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
command: "pnpm dlx create-react-app",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "pnpm exec — proxy runs (may resolve packages)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
command: "pnpm exec tsc",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "yarn dlx — proxy runs (downloads and runs package)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
command: "yarn dlx create-react-app",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "bun x — proxy runs (bun's npx equivalent)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
command: "bun x create-vite",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
// Commands where proxy is safely skipped
{
name: "npm outdated — proxy skipped (read-only registry check)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm outdated",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "npm list — proxy skipped (lists installed packages)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm list",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "pnpm why — proxy skipped (dependency reason lookup)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultPnpmPackageManagerConfig()) },
command: "pnpm why express",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "yarn why — proxy skipped (dependency reason lookup)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
command: "yarn why express",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
// Script execution via run
{
name: "npm run dev — proxy skipped (executes local script, no registry contact)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm run dev",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "yarn run build — proxy skipped (executes local script)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultYarnPackageManagerConfig()) },
command: "yarn run build",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "bun run test — proxy skipped (executes local script)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultBunPackageManagerConfig()) },
command: "bun run test",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
// False positive regression: package/script names matching NonDownloadCommands words
{
name: "npm exec test — proxy runs (test is package arg, not subcommand)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm exec test",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "npm update config — proxy runs (config is package name, not subcommand)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm update config",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "npm publish --tag version — proxy runs (version is flag value, not subcommand)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm publish --tag version",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
// Unknown commands default to proxy running (fail safe)
{
name: "unknown npm subcommand — proxy runs (fail safe)",
pm: func() (*npmPackageManager, error) { return NewNpmPackageManager(DefaultNpmPackageManagerConfig()) },
command: "npm some-future-command",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pm, err := tc.pm()
assert.NoError(t, err)
parsed, err := pm.ParseCommand(strings.Split(tc.command, " "))
assert.NoError(t, err)
assert.Equal(t, tc.isKnownNonDownloadCmd, parsed.IsKnownNonDownloadCommand)
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
assert.Equal(t, !tc.isKnownNonDownloadCmd, parsed.MayDownloadPackages())
})
}
}
+29
View File
@@ -2,6 +2,8 @@ package packagemanager
import (
"context"
"slices"
"strings"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
@@ -38,13 +40,27 @@ type ParsedCommand struct {
// ManifestFiles contains the list of manifest files to install from
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
ManifestFiles []string
// IsKnownNonDownloadCommand is true for commands that are known to not download packages
// (e.g., npm ls, pip list, yarn why). Used by the proxy to decide whether to skip
// interception when proxy_install_only is enabled. Unknown commands default to false so
// the proxy runs — fail safe when a new subcommand is added to a package manager.
IsKnownNonDownloadCommand bool
}
// IsInstallationCommand returns true if command installs packages (explicit targets or from manifest).
// This is used by guard mode where we need to know which packages are being installed.
func (pc *ParsedCommand) IsInstallationCommand() bool {
return pc.HasInstallTarget() || pc.HasManifestInstall()
}
// MayDownloadPackages returns true if the command may download packages from a registry.
// Returns false only for commands explicitly known to be non-download (e.g., npm ls, pip list).
// Unknown commands return true by default — fail safe when new package manager subcommands appear.
func (pc *ParsedCommand) MayDownloadPackages() bool {
return !pc.IsKnownNonDownloadCommand
}
func (pc *ParsedCommand) HasInstallTarget() bool {
return len(pc.InstallTargets) > 0
}
@@ -57,6 +73,19 @@ func (pc *ParsedCommand) ShouldExtractFromManifest() bool {
return pc.IsManifestInstall && !pc.HasInstallTarget()
}
// isFirstNonFlagArgInList checks if the first non-flag argument in args is in nonDownloadCmds.
// Only the first non-flag arg (the subcommand) is checked to avoid false positives when package
// names or script arguments happen to match a known non-download command.
func isFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool {
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
return slices.Contains(nonDownloadCmds, arg)
}
return false
}
// PackageManager is the contract for implementing a package manager
type PackageManager interface {
// Name of the package manager implementation
+39 -11
View File
@@ -18,35 +18,60 @@ type pypiCommandParser interface {
}
type PypiPackageManagerConfig struct {
InstallCommands []string
CommandName string
InstallCommands []string
NonDownloadCommands []string
CommandName string
}
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"install"},
CommandName: "pip",
NonDownloadCommands: []string{
// Removal — no registry download
"uninstall",
// Inspection / read-only
"list", "show", "check", "freeze", "config",
},
CommandName: "pip",
}
}
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"install"},
CommandName: "pip3",
NonDownloadCommands: []string{
"uninstall",
"list", "show", "check", "freeze", "config",
},
CommandName: "pip3",
}
}
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"add", "install"},
CommandName: "uv",
// uv uses nested subcommands (e.g., `uv pip list`, `uv tool run`) making it
// unsafe to classify commands by a single arg scan. Run the proxy for all
// non-install uv commands to avoid missing coverage.
NonDownloadCommands: []string{},
CommandName: "uv",
}
}
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"add"},
CommandName: "poetry",
NonDownloadCommands: []string{
// Script runners — "run" executes a command in the venv (e.g., `poetry run uvicorn app:app`).
// "shell" activates the venv shell. Both may start long-running processes and must not
// have proxy env vars set against them.
"run", "shell",
// Removal
"remove",
// Inspection / read-only
"show", "config", "check",
},
CommandName: "poetry",
}
}
@@ -123,8 +148,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
}
if installCmdIndex == -1 {
// No install command found, return as-is
return &ParsedCommand{Command: command}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command
@@ -242,7 +266,12 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
}
if installCmdIndex == -1 {
// No install command found, return as-is
for _, arg := range args {
if slices.Contains(u.config.NonDownloadCommands, arg) {
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: true}, nil
}
}
return &ParsedCommand{Command: command}, nil
}
@@ -340,8 +369,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
}
if installCmdIndex == -1 {
// No install command found, return as-is
return &ParsedCommand{Command: command}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command
+112
View File
@@ -1,6 +1,7 @@
package packagemanager
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -856,3 +857,114 @@ func TestPypiConvertWildcardConstraint(t *testing.T) {
})
}
}
func TestPypiProxyBehavior(t *testing.T) {
cases := []struct {
name string
pm func() (*pypiPackageManager, error)
command string
isKnownNonDownloadCmd bool // proxy skipped when proxy_install_only=true
isInstallationCommand bool
}{
// Commands where proxy MUST run
{
name: "poetry update — proxy runs (may download)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
command: "poetry update",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "poetry add — proxy runs (installation command)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
command: "poetry add django",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "uv sync — proxy runs (manifest install)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
command: "uv sync",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "pip download — proxy runs (explicitly downloads packages)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
command: "pip download requests",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "pip3 download — proxy runs (explicitly downloads packages)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPip3PackageManagerConfig()) },
command: "pip3 download django",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "uv pip download — proxy runs (uv has complex subcommands, fail safe)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
command: "uv pip download requests",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "uv run — proxy runs (auto-installs script dependencies)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultUvPackageManagerConfig()) },
command: "uv run script.py",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
// Commands where proxy is safely skipped
{
name: "pip list — proxy skipped (lists installed packages)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
command: "pip list",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "pip show — proxy skipped (shows package metadata)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
command: "pip show requests",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "poetry show — proxy skipped (shows dependency info)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
command: "poetry show",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "poetry check — proxy skipped (validates pyproject.toml)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPoetryPackageManagerConfig()) },
command: "poetry check",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
// Unknown commands default to proxy running (fail safe)
{
name: "unknown pip subcommand — proxy runs (fail safe)",
pm: func() (*pypiPackageManager, error) { return NewPypiPackageManager(DefaultPipPackageManagerConfig()) },
command: "pip some-future-command",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pm, err := tc.pm()
assert.NoError(t, err)
parsed, err := pm.ParseCommand(strings.Split(tc.command, " "))
assert.NoError(t, err)
assert.Equal(t, tc.isKnownNonDownloadCmd, parsed.IsKnownNonDownloadCommand)
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
assert.Equal(t, !tc.isKnownNonDownloadCmd, parsed.MayDownloadPackages())
})
}
}