feat: consolidate proxy config into structured section and add support for custom commands to skip proxy (#240)

* feat: add ProxyConfig struct with per-PM skip_commands and legacy fallback

* feat: consolidate proxy config into structured section with backward compat

Replaces flat proxy_mode/proxy_install_only keys with a structured proxy
section supporting per-package-manager skip_commands. Legacy keys are
respected via fallback when user's config lacks the new proxy section.
Removes deprecated experimental_proxy_mode config and flag.

* fix: env var resolution for nested config keys and deduplicate skip command matching

- Add "." to "_" in Viper env key replacer so nested keys like
  sandbox.enabled resolve from PMG_SANDBOX_ENABLED (was silently broken)
- Export IsFirstNonFlagArgInList and remove duplicate from proxy_flow.go
- Add table-driven tests for skip command matching with real-world cases
- Remove redundant env var test

* docs: update proxy configuration and env var documentation

Update config.md env var table to reflect new proxy.enabled and
proxy.install_only keys. Add proxy configuration section to proxy.md
covering config structure, per-PM skip commands, CLI flags, and env vars.

* fix: legacy fallback precedence
This commit is contained in:
Sahil Bansal
2026-05-06 18:27:23 +05:30
committed by GitHub
parent d6755d3f44
commit d1dd2560a4
15 changed files with 423 additions and 71 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ func executeSetupInfo() error {
configEntries := make(map[string]string)
configEntries["Config File"] = cfg.ConfigFilePath()
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.ProxyInstallOnly)
configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly)
ui.PrintInfoSection("Configuration", configEntries)
// Shell Integration section
+2 -6
View File
@@ -28,10 +28,8 @@ func ApplyCobraFlags(cmd *cobra.Command) {
globalConfig.Config.Paranoid, "Enable high-security defaults (treat suspicious as malicious)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log",
globalConfig.Config.SkipEventLogging, "Skip event logging")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode",
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ProxyMode, "proxy-mode",
globalConfig.Config.ProxyMode, "Use proxy based interception")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Proxy.Enabled, "proxy-mode",
globalConfig.Config.Proxy.Enabled, "Use proxy based interception")
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",
@@ -44,8 +42,6 @@ func ApplyCobraFlags(cmd *cobra.Command) {
cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown",
false, "Skip dependency cooldown enforcement")
// Hide the experimental proxy mode flag but keep it for backward compatibility
_ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode")
}
// FinalizeDependencyCooldownOverride disables dependency cooldown in the global
+20 -13
View File
@@ -65,17 +65,10 @@ type Config struct {
// EventLogRetentionDays is the number of days to retain event logs.
EventLogRetentionDays int `mapstructure:"event_log_retention_days"`
// ProxyMode enables proxy-based package interception when supported by package managers.
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
// Deprecated: Use Proxy.Enabled instead. Kept for backward compatibility with old config files.
ProxyMode bool `mapstructure:"proxy_mode"`
// ExperimentalProxyMode is same as ProxyMode. Kept here for backward compatibility because
// 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.
// Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files.
ProxyInstallOnly bool `mapstructure:"proxy_install_only"`
// Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
@@ -88,6 +81,8 @@ type Config struct {
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
Cloud CloudConfig `mapstructure:"cloud"`
Proxy ProxyConfig `mapstructure:"proxy"`
}
// CloudConfig configures audit event sync to SafeDep Cloud.
@@ -96,6 +91,16 @@ type CloudConfig struct {
EndpointID string `mapstructure:"endpoint_id"`
}
type ProxyPolicy struct {
SkipCommands []string `mapstructure:"skip_commands"`
}
type ProxyConfig struct {
Enabled bool `mapstructure:"enabled"`
InstallOnly bool `mapstructure:"install_only"`
Policies map[string]ProxyPolicy `mapstructure:"policies"`
}
// SandboxConfig configures the sandbox system for isolating package manager processes.
type SandboxConfig struct {
// Enabled enables sandbox mode (opt-in by default for backward compatibility).
@@ -199,10 +204,8 @@ func (r *RuntimeConfig) ConfigDir() string {
return r.configDir
}
// IsProxyModeEnabled is a helper function to check for proxy mode with
// support for backward compatibility
func (r *RuntimeConfig) IsProxyModeEnabled() bool {
return (r.Config.ExperimentalProxyMode || r.Config.ProxyMode)
return r.Config.Proxy.Enabled
}
// SandboxAllowType represents the type of a sandbox allow override.
@@ -248,7 +251,6 @@ func DefaultConfig() RuntimeConfig {
DisableTelemetry: false,
EventLogRetentionDays: 7,
SkipEventLogging: false,
ExperimentalProxyMode: false,
TrustedPackages: []TrustedPackage{},
ProxyMode: true,
Verbosity: VerbosityNormal,
@@ -263,6 +265,11 @@ func DefaultConfig() RuntimeConfig {
Cloud: CloudConfig{
Enabled: false,
},
Proxy: ProxyConfig{
Enabled: true,
InstallOnly: false,
Policies: map[string]ProxyPolicy{},
},
},
DryRun: false,
InsecureInstallation: insecureInstallation,
+16 -7
View File
@@ -32,18 +32,27 @@ skip_event_logging: false
# This is the number of days to retain event logs.
event_log_retention_days: 7
# Proxy mode. Default is true.
# Proxy configuration.
# When enabled, PMG uses a proxy-based interception approach instead of the
# default guard-based analysis. The proxy intercepts package manager requests in real-time
# and analyzes packages as they are downloaded. Proxy mode may not work in all environments,
# and can be disabled to fall back to the guard-based analysis.
proxy_mode: true
proxy:
enabled: 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
# When true, only install commands are proxied. Other commands
# (e.g., npm ls, pip list) bypass the proxy and execute directly.
install_only: false
# Per-package-manager proxy policies.
# Additional commands to bypass the proxy.
# Example:
# policies:
# pip:
# skip_commands: ["list", "show"]
policies:
npm:
skip_commands: []
# 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.
+102 -29
View File
@@ -28,7 +28,7 @@ func TestConfigHasDefaultValues(t *testing.T) {
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)
assert.Equal(t, false, config.Config.Proxy.InstallOnly)
})
t.Run("when no config directory is set", func(t *testing.T) {
@@ -69,7 +69,7 @@ func TestPartialConfigFallsBackToDefaults(t *testing.T) {
// Missing keys should fall back to DefaultConfig() values, not Go zero values
defaults := DefaultConfig().Config
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth)
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled)
assert.Equal(t, defaults.Verbosity, config.Config.Verbosity)
assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays)
assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled)
@@ -102,14 +102,14 @@ func TestPartialConfigWithNestedOverride(t *testing.T) {
// Top-level fields should fall back to defaults
assert.Equal(t, defaults.Transitive, config.Config.Transitive)
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth)
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
assert.Equal(t, defaults.Proxy.Enabled, config.Config.Proxy.Enabled)
}
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)
assert.Equal(t, false, Get().Config.Proxy.InstallOnly)
})
t.Run("can be set to true via config file", func(t *testing.T) {
@@ -117,18 +117,18 @@ func TestProxyInstallOnlyConfig(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644)
err := os.WriteFile(configPath, []byte("proxy:\n install_only: true\n"), 0o644)
require.NoError(t, err)
initConfig()
assert.Equal(t, true, Get().Config.ProxyInstallOnly)
assert.Equal(t, true, Get().Config.Proxy.InstallOnly)
})
}
// 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) {
t.Run("env var sets legacy flat field", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
@@ -138,7 +138,8 @@ func TestConfigPrecedence(t *testing.T) {
require.NoError(t, err)
initConfig()
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should override config file")
// PMG_PROXY_INSTALL_ONLY maps to the flat proxy_install_only key, not nested proxy.install_only
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should set legacy flat field")
})
t.Run("config file overrides default", func(t *testing.T) {
@@ -147,11 +148,11 @@ func TestConfigPrecedence(t *testing.T) {
t.Setenv("PMG_PROXY_INSTALL_ONLY", "")
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644)
err := os.WriteFile(configPath, []byte("proxy:\n install_only: true\n"), 0o644)
require.NoError(t, err)
initConfig()
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "config file should override default")
assert.Equal(t, true, Get().Config.Proxy.InstallOnly, "config file should override default")
})
t.Run("telemetry can be disabled via config", func(t *testing.T) {
@@ -167,27 +168,12 @@ func TestConfigPrecedence(t *testing.T) {
assert.Equal(t, true, Get().Config.DisableTelemetry, "config file should disable telemetry")
})
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")
t.Setenv("PMG_PARANOID", "true")
initConfig()
assert.Equal(t, true, Get().Config.ProxyInstallOnly, "env var should work even without a config file")
assert.Equal(t, true, Get().Config.Paranoid, "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) {
@@ -200,7 +186,8 @@ func TestConfigPrecedence(t *testing.T) {
require.NoError(t, err)
initConfig()
assert.Equal(t, false, Get().Config.ProxyInstallOnly, "should use default when key absent from config and env")
assert.Equal(t, false, Get().Config.Proxy.InstallOnly, "should use default when key absent from config and env")
assert.Equal(t, false, Get().Config.ProxyInstallOnly, "legacy flat field should also default to false")
})
t.Run("cobra flag overrides env var", func(t *testing.T) {
@@ -308,7 +295,7 @@ func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
assert.Contains(t, raw, "transitive_depth: 10")
// New keys from template added
assert.Contains(t, raw, "proxy_mode:")
assert.Contains(t, raw, "proxy:")
assert.Contains(t, raw, "sandbox:")
assert.Contains(t, raw, "verbosity:")
}
@@ -329,3 +316,89 @@ func TestWriteTemplateConfigCreatesNewFile(t *testing.T) {
// Should be the full template
assert.Equal(t, templateConfig, string(result))
}
func TestProxyConfigSection(t *testing.T) {
t.Run("defaults to enabled with install_only false", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
initConfig()
cfg := Get()
assert.Equal(t, true, cfg.Config.Proxy.Enabled)
assert.Equal(t, false, cfg.Config.Proxy.InstallOnly)
assert.NotNil(t, cfg.Config.Proxy.Policies)
})
t.Run("reads proxy section from config file", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configYAML := `proxy:
enabled: true
install_only: true
policies:
npm:
skip_commands: ["my-script", "dev"]
`
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(configYAML), 0o644)
require.NoError(t, err)
initConfig()
cfg := Get()
assert.Equal(t, true, cfg.Config.Proxy.Enabled)
assert.Equal(t, true, cfg.Config.Proxy.InstallOnly)
assert.Equal(t, []string{"my-script", "dev"}, cfg.Config.Proxy.Policies["npm"].SkipCommands)
})
t.Run("falls back to legacy keys from config file", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configYAML := `proxy_mode: false
proxy_install_only: true
`
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(configYAML), 0o644)
require.NoError(t, err)
initConfig()
cfg := Get()
assert.Equal(t, false, cfg.Config.Proxy.Enabled)
assert.Equal(t, true, cfg.Config.Proxy.InstallOnly)
})
t.Run("falls back to legacy keys from env vars", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
t.Setenv("PMG_PROXY_MODE", "false")
t.Setenv("PMG_PROXY_INSTALL_ONLY", "true")
initConfig()
cfg := Get()
assert.Equal(t, false, cfg.Config.Proxy.Enabled, "PMG_PROXY_MODE=false should set Proxy.Enabled=false")
assert.Equal(t, true, cfg.Config.Proxy.InstallOnly, "PMG_PROXY_INSTALL_ONLY=true should set Proxy.InstallOnly=true")
})
t.Run("new proxy section takes precedence over old keys", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configYAML := `proxy_mode: false
proxy_install_only: true
proxy:
enabled: true
install_only: false
`
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(configYAML), 0o644)
require.NoError(t, err)
initConfig()
cfg := Get()
assert.Equal(t, true, cfg.Config.Proxy.Enabled, "new proxy.enabled should win over old proxy_mode")
assert.Equal(t, false, cfg.Config.Proxy.InstallOnly, "new proxy.install_only should win over old proxy_install_only")
})
}
+42 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
// loadViperConfig loads the configuration using Viper.
@@ -21,7 +22,7 @@ func loadViperConfig() error {
v.SetConfigType("yaml")
v.SetEnvPrefix("PMG")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
// 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
@@ -44,5 +45,45 @@ func loadViperConfig() error {
}
globalConfig.Config = merged
// Resolve proxy config: new proxy section > legacy flat keys.
// Viper can't distinguish "value from template" vs "value from user config"
// (v.IsSet is always true for template keys), so we check the raw user file.
if !hasProxySectionInFile(configPath) {
applyProxyLegacyFallback(v)
}
return nil
}
// hasProxySectionInFile checks whether the user's config file contains a
// top-level "proxy" key. Returns false if the file doesn't exist or can't
// be parsed.
func hasProxySectionInFile(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil {
return false
}
_, ok := raw["proxy"]
return ok
}
// applyProxyLegacyFallback populates the new Proxy struct from deprecated
// flat keys when the user's config file does not have a proxy: section.
// New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence
// over legacy config file keys to respect the documented precedence order.
func applyProxyLegacyFallback(v *viper.Viper) {
if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") {
globalConfig.Config.Proxy.Enabled = v.GetBool("proxy_mode")
}
if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") {
globalConfig.Config.Proxy.InstallOnly = v.GetBool("proxy_install_only")
}
}
+40
View File
@@ -0,0 +1,40 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewEnvVarNotOverriddenByLegacyConfigFile(t *testing.T) {
t.Run("PMG_PROXY_ENABLED wins over proxy_mode in config file", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
t.Setenv("PMG_PROXY_ENABLED", "false")
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("proxy_mode: true\n"), 0o644)
require.NoError(t, err)
initConfig()
assert.Equal(t, false, Get().Config.Proxy.Enabled,
"PMG_PROXY_ENABLED=false should not be overridden by proxy_mode: true in config")
})
t.Run("PMG_PROXY_INSTALL_ONLY wins over proxy_install_only in 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.Proxy.InstallOnly,
"PMG_PROXY_INSTALL_ONLY=true should not be overridden by proxy_install_only: false in config")
})
}
+4 -2
View File
@@ -31,14 +31,16 @@ file. This is useful for CI/CD pipelines or temporary overrides.
|---|---|
| `transitive` | `PMG_TRANSITIVE` |
| `paranoid` | `PMG_PARANOID` |
| `proxy_mode` | `PMG_PROXY_MODE` |
| `proxy_install_only` | `PMG_PROXY_INSTALL_ONLY` |
| `proxy.enabled` | `PMG_PROXY_ENABLED` |
| `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` |
Legacy environment variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat keys) are still supported when the `proxy:` section does not exist in the config file.
**Example:**
```bash
+49
View File
@@ -3,6 +3,55 @@
A generic, extensible HTTP/HTTPS proxy server with man-in-the-middle (MITM) capabilities for intercepting and analyzing package manager traffic.
Built with [goproxy](https://github.com/elazarl/goproxy) library.
## Configuration
Proxy behavior is configured under the `proxy:` section in `config.yml`:
```yaml
proxy:
enabled: true
install_only: false
policies:
npm:
skip_commands: ["my-script"]
```
| Key | Default | Description |
|---|---|---|
| `enabled` | `true` | Enable proxy-based interception. When `false`, PMG falls back to guard-based analysis. |
| `install_only` | `false` | When `true`, only install commands are proxied. Other commands (e.g., `npm ls`, `pip list`) bypass the proxy and execute directly. |
| `policies` | `{}` | Per-package-manager policies. Each entry maps a package manager name to a policy with `skip_commands`. |
### Per-package-manager skip commands
The `policies` section lets you define additional commands that should bypass the proxy for specific package managers:
```yaml
proxy:
policies:
npm:
skip_commands: ["dev", "my-script"]
pip:
skip_commands: ["list", "show"]
```
Commands in `skip_commands` are matched against the first non-flag argument. For example, `npm dev` would match `dev`, but `npm install dev` would not since `install` is the first non-flag argument.
### CLI flags
| Flag | Description |
|---|---|
| `--proxy-mode` | Override `proxy.enabled` |
### Environment variables
| Variable | Description |
|---|---|
| `PMG_PROXY_ENABLED` | Override `proxy.enabled` |
| `PMG_PROXY_INSTALL_ONLY` | Override `proxy.install_only` |
Legacy variables `PMG_PROXY_MODE` and `PMG_PROXY_INSTALL_ONLY` (for the old flat config keys) are still supported when the `proxy:` section does not exist in the config file.
## Features
- Selective interception of HTTPS traffic
+13 -4
View File
@@ -52,12 +52,20 @@ 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)")
// Skip proxy for commands that don't download packages when install_only is enabled
if cfg.Config.Proxy.InstallOnly && !parsedCmd.MayDownloadPackages() {
log.Debugf("Skipping proxy for non-download command (install_only=true)")
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun)
}
// Skip proxy for user-defined skip commands
if policy, ok := cfg.Config.Proxy.Policies[f.pm.Name()]; ok && len(policy.SkipCommands) > 0 {
if packagemanager.IsFirstNonFlagArgInList(parsedCmd.Command.Args, policy.SkipCommands) {
log.Debugf("Skipping proxy for user-defined skip command")
return runner.Execute(ctx, parsedCmd, f.pm.Name(), cfg.DryRun)
}
}
// Initialize report data at the start
reportData := ui.NewReportData()
reportData.PackageManagerName = f.pm.Name()
@@ -99,7 +107,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
// Check if dry-run mode is enabled
if cfg.DryRun {
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())
log.Infof("Dry-run mode: Would execute %s with proxy protection", f.pm.Name())
log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
reportData.Outcome = ui.OutcomeDryRun
@@ -560,3 +568,4 @@ func (f *proxyFlow) handlePackageManagerExecutionError(err error) error {
WithHelp("Check the package manager command and its arguments").
Wrap(err)
}
+1 -1
View File
@@ -165,7 +165,7 @@ func logDebugContext() {
log.Debugf("PMG %s (commit: %s) running on %s/%s with %s",
appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version())
log.Debugf("Using config file: %s", cfg.ConfigFilePath())
log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.ProxyInstallOnly)
log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.Proxy.InstallOnly)
log.Debugf("Sandbox enabled: %t, enforce always: %t", cfg.Config.Sandbox.Enabled, cfg.Config.Sandbox.EnforceAlways)
log.Debugf("Transitive analysis enabled: %t (depth: %d), paranoid: %t", cfg.Config.Transitive, cfg.Config.TransitiveDepth, cfg.Config.Paranoid)
log.Debugf("Dependency cooldown enabled: %t (days: %d)", cfg.Config.DependencyCooldown.Enabled, cfg.Config.DependencyCooldown.Days)
+1 -1
View File
@@ -134,7 +134,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}
if installCmdIndex == -1 {
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command
+4 -4
View File
@@ -47,7 +47,7 @@ type ParsedCommand struct {
// 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
// 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
}
@@ -77,10 +77,10 @@ func (pc *ParsedCommand) ShouldExtractFromManifest() bool {
return pc.IsManifestInstall && !pc.HasInstallTarget()
}
// isFirstNonFlagArgInList checks if the first non-flag argument in args is in nonDownloadCmds.
// IsFirstNonFlagArgInList checks if the first non-flag argument in args is in the given list.
// 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 {
// names or script arguments happen to match a known command.
func IsFirstNonFlagArgInList(args []string, nonDownloadCmds []string) bool {
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
+126
View File
@@ -0,0 +1,126 @@
package packagemanager
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsFirstNonFlagArgInList(t *testing.T) {
tests := []struct {
name string
args []string
list []string
expected bool
}{
{
name: "exact match on first arg",
args: []string{"list", "express"},
list: []string{"list", "show"},
expected: true,
},
{
name: "no match",
args: []string{"install", "express"},
list: []string{"list", "show"},
expected: false,
},
{
name: "flags before subcommand are skipped",
args: []string{"--global", "list"},
list: []string{"list"},
expected: true,
},
{
name: "only first non-flag arg is checked",
args: []string{"install", "list"},
list: []string{"list"},
expected: false,
},
{
name: "empty args",
args: []string{},
list: []string{"list"},
expected: false,
},
{
name: "empty list",
args: []string{"list"},
list: []string{},
expected: false,
},
{
name: "all flags no subcommand",
args: []string{"--verbose", "-g", "--save"},
list: []string{"list"},
expected: false,
},
{
name: "short flag before subcommand",
args: []string{"-g", "list"},
list: []string{"list"},
expected: true,
},
{
name: "package name matches list entry but is second arg",
args: []string{"install", "dev"},
list: []string{"dev"},
expected: false,
},
{
name: "partial match does not count",
args: []string{"listing"},
list: []string{"list"},
expected: false,
},
{
name: "case sensitive",
args: []string{"List"},
list: []string{"list"},
expected: false,
},
{
name: "npm run script matching skip command",
args: []string{"run", "dev"},
list: []string{"run"},
expected: true,
},
{
name: "flag-like value after subcommand is irrelevant",
args: []string{"install", "--save", "express"},
list: []string{"install"},
expected: true,
},
{
name: "real world: npm ls",
args: []string{"ls"},
list: []string{"ls", "list", "outdated", "why", "explain"},
expected: true,
},
{
name: "real world: pip install is not in non-download list",
args: []string{"install", "requests"},
list: []string{"list", "show", "freeze", "check"},
expected: false,
},
{
name: "real world: pip list",
args: []string{"list", "--outdated"},
list: []string{"list", "show", "freeze", "check"},
expected: true,
},
{
name: "flag with value before subcommand",
args: []string{"--registry", "https://registry.npmjs.org", "list"},
list: []string{"list"},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := IsFirstNonFlagArgInList(tc.args, tc.list)
assert.Equal(t, tc.expected, result)
})
}
}
+2 -2
View File
@@ -148,7 +148,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
}
if installCmdIndex == -1 {
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command
@@ -375,7 +375,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
}
if installCmdIndex == -1 {
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: isFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
}
// Extract arguments after the install command