fix: Opt-in lockdown for global config (#278)

* fix: Reject overriding managed flags

* fix: Lockdown overrides when global config present

* fix: Opt-in lock-down enforcement for global config

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-05-21 16:37:26 +05:30
committed by GitHub
parent b15ce33fe4
commit 0e3bb52f8c
9 changed files with 487 additions and 51 deletions
+4 -1
View File
@@ -45,7 +45,10 @@ func executeSetupInfo() error {
configEntries["Config File"] = cfg.ConfigFilePath() configEntries["Config File"] = cfg.ConfigFilePath()
configSource := "user" configSource := "user"
if cfg.IsManaged() { if cfg.IsManaged() {
configSource = "global (managed)" configSource = "global"
if cfg.IsLocked() {
configSource = "global (locked)"
}
} }
configEntries["Config Source"] = configSource configEntries["Config Source"] = configSource
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled()) configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
+121 -28
View File
@@ -2,8 +2,10 @@ package config
import ( import (
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
var skipDependencyCooldown bool var skipDependencyCooldown bool
@@ -11,37 +13,128 @@ var skipDependencyCooldown bool
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing. // sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
var sandboxAllowRaw []string var sandboxAllowRaw []string
// ApplyCobraFlags applies the cobra flags to the command. // flagSpec declares a pmg flag once: how to bind it into cobra (bind) and the
// These flags are local concern of the config package. This helper function is used // metadata used to reason about it (managed). configFlagSpecs is the single
// to bind them to the Cobra. The default values are taken from the global configuration, // source of truth, so the cobra wiring and policy decisions cannot drift apart.
// allowing for overriding the configuration at runtime. type flagSpec struct {
name string
usage string
// true when the globally managed config governs this value
managed bool
// bind registers the flag on fs. It owns the type, target field, and default
// (read at bind time), keeping the flag tied to its config field with
// compile-time safety rather than a stringly-typed key.
bind func(fs *pflag.FlagSet, name, usage string)
}
var configFlagSpecs = []flagSpec{
{
name: "transitive", usage: "Resolve transitive dependencies", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.Transitive, name, globalConfig.Config.Transitive, usage)
},
},
{
name: "transitive-depth", usage: "Maximum depth of transitive dependencies to resolve", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.IntVar(&globalConfig.Config.TransitiveDepth, name, globalConfig.Config.TransitiveDepth, usage)
},
},
{
name: "include-dev-dependencies", usage: "Include dev dependencies in the dependency graph (slows down resolution)", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.IncludeDevDependencies, name, globalConfig.Config.IncludeDevDependencies, usage)
},
},
{
name: "dry-run", usage: "Dry run skips execution of package manager", managed: false,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.DryRun, name, globalConfig.DryRun, usage)
},
},
{
name: "paranoid", usage: "Enable high-security defaults (treat suspicious as malicious)", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.Paranoid, name, globalConfig.Config.Paranoid, usage)
},
},
{
name: "skip-event-log", usage: "Skip event logging", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.SkipEventLogging, name, globalConfig.Config.SkipEventLogging, usage)
},
},
{
name: "proxy-mode", usage: "Use proxy based interception", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.Proxy.Enabled, name, globalConfig.Config.Proxy.Enabled, usage)
},
},
{
name: "sandbox", usage: "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.Sandbox.Enabled, name, globalConfig.Config.Sandbox.Enabled, usage)
},
},
{
name: "sandbox-enforce", usage: "Apply sandbox to all commands, not just install commands (requires --sandbox)", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, name, globalConfig.Config.Sandbox.EnforceAlways, usage)
},
},
{
name: "sandbox-profile", usage: "Override sandbox policy profile (built-in name or path to custom YAML)", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.StringVar(&globalConfig.SandboxProfileOverride, name, globalConfig.SandboxProfileOverride, usage)
},
},
{
name: "sandbox-allow", usage: "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.StringArrayVar(&sandboxAllowRaw, name, nil, usage)
},
},
{
name: "skip-dependency-cooldown", usage: "Skip dependency cooldown enforcement", managed: true,
bind: func(fs *pflag.FlagSet, name, usage string) {
fs.BoolVar(&skipDependencyCooldown, name, false, usage)
},
},
}
// ApplyCobraFlags binds the config flags onto cmd as persistent flags. Defaults
// are read from the current global configuration, allowing runtime overrides.
func ApplyCobraFlags(cmd *cobra.Command) { func ApplyCobraFlags(cmd *cobra.Command) {
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Transitive, "transitive", fs := cmd.PersistentFlags()
globalConfig.Config.Transitive, "Resolve transitive dependencies") for _, f := range configFlagSpecs {
cmd.PersistentFlags().IntVar(&globalConfig.Config.TransitiveDepth, "transitive-depth", f.bind(fs, f.name, f.usage)
globalConfig.Config.TransitiveDepth, "Maximum depth of transitive dependencies to resolve") }
cmd.PersistentFlags().BoolVar(&globalConfig.Config.IncludeDevDependencies, "include-dev-dependencies", }
globalConfig.Config.IncludeDevDependencies, "Include dev dependencies in the dependency graph (slows down resolution)")
cmd.PersistentFlags().BoolVar(&globalConfig.DryRun, "dry-run",
globalConfig.DryRun, "Dry run skips execution of package manager")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Paranoid, "paranoid",
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.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",
globalConfig.Config.Sandbox.EnforceAlways, "Apply sandbox to all commands, not just install commands (requires --sandbox)")
cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile",
globalConfig.SandboxProfileOverride, "Override sandbox policy profile (built-in name or path to custom YAML)")
cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow",
nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind")
cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown", // RejectManagedFlagOverrides fails when the active config is a locked global
false, "Skip dependency cooldown enforcement") // config and the user explicitly set a flag whose value that config governs.
// Operational flags (managed == false) are unaffected, and an unlocked managed
// config allows flag overrides. Call it after flag parsing.
func RejectManagedFlagOverrides(cmd *cobra.Command) error {
if !Get().IsLocked() {
return nil
}
var offending []string
for _, f := range configFlagSpecs {
if f.managed && cmd.Flags().Changed(f.name) {
offending = append(offending, "--"+f.name)
}
}
if len(offending) == 0 {
return nil
}
return managedError(fmt.Sprintf("these flags cannot override the globally managed configuration (%s): %s",
globalConfig.configFilePath, strings.Join(offending, ", ")))
} }
// FinalizeDependencyCooldownOverride disables dependency cooldown in the global // FinalizeDependencyCooldownOverride disables dependency cooldown in the global
+117
View File
@@ -0,0 +1,117 @@
package config
import (
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withLockedState swaps globalConfig for a fresh managed config with the
// requested lockdown state, and restores the original afterwards. Locked implies
// managed, matching production (initConfig sets configLocked = IsManaged() && ...).
func withLockedState(t *testing.T, locked bool) {
t.Helper()
orig := globalConfig
t.Cleanup(func() { globalConfig = orig })
cfg := DefaultConfig()
globalConfig = &cfg
globalConfig.configFilePath = "/global/config.yml" // managed: active path differs from user path
globalConfig.userConfigFilePath = "/user/config.yml"
globalConfig.configLocked = locked
}
func TestRejectManagedFlagOverrides(t *testing.T) {
tests := []struct {
name string
locked bool
args []string
wantErr bool
}{
{"locked blocks a managed flag", true, []string{"--paranoid"}, true},
{"locked blocks sandbox-allow", true, []string{"--sandbox-allow", "read=/tmp"}, true},
{"locked allows dry-run", true, []string{"--dry-run"}, false},
{"locked allows no flags", true, nil, false},
{"unlocked allows a managed flag", false, []string{"--paranoid"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withLockedState(t, tc.locked)
cmd := &cobra.Command{Use: "test", Run: func(*cobra.Command, []string) {}}
ApplyCobraFlags(cmd)
require.NoError(t, cmd.ParseFlags(tc.args))
err := RejectManagedFlagOverrides(cmd)
if tc.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "globally managed")
} else {
assert.NoError(t, err)
}
})
}
}
// Proves the check sees managed flags set as inherited persistent flags on a
// subcommand, which is how they reach the real root PersistentPreRun.
func TestRejectManagedFlagOverridesDetectsInheritedFlag(t *testing.T) {
withLockedState(t, true)
root := &cobra.Command{Use: "pmg"}
ApplyCobraFlags(root)
var checked bool
child := &cobra.Command{
Use: "install",
RunE: func(cmd *cobra.Command, _ []string) error {
checked = true
return RejectManagedFlagOverrides(cmd)
},
}
root.AddCommand(child)
root.SetArgs([]string{"install", "--sandbox=false"})
err := root.Execute()
require.True(t, checked)
require.Error(t, err)
assert.Contains(t, err.Error(), "globally managed")
}
// Proves the SSOT table is internally consistent: every spec actually binds a
// flag, every registered flag traces back to a spec (no out-of-band flags), and
// the managed classification matches intent. Catches accidental managed flips
// and config flags added without classification.
func TestConfigFlagSpecsSSOT(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
ApplyCobraFlags(cmd)
specByName := make(map[string]flagSpec, len(configFlagSpecs))
gotManaged := make(map[string]bool)
for _, f := range configFlagSpecs {
specByName[f.name] = f
require.NotNil(t, cmd.PersistentFlags().Lookup(f.name), "spec %q is not registered by ApplyCobraFlags", f.name)
if f.managed {
gotManaged[f.name] = true
}
}
// No flag is registered outside the SSOT table.
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
_, ok := specByName[f.Name]
assert.True(t, ok, "flag --%s is registered but has no flagSpec", f.Name)
})
wantManaged := map[string]bool{
"transitive": true, "transitive-depth": true, "include-dev-dependencies": true,
"paranoid": true, "skip-event-log": true, "proxy-mode": true,
"sandbox": true, "sandbox-enforce": true, "sandbox-profile": true,
"sandbox-allow": true, "skip-dependency-cooldown": true,
}
assert.Equal(t, wantManaged, gotManaged, "managed flag classification changed unexpectedly")
}
+30 -10
View File
@@ -6,7 +6,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"time" "time"
_ "embed" _ "embed"
@@ -202,6 +201,7 @@ type RuntimeConfig struct {
configDir string configDir string
configFilePath string // active config: globally managed file if present, else per-user configFilePath string // active config: globally managed file if present, else per-user
userConfigFilePath string // per-user config file, used for writes and removal userConfigFilePath string // per-user config file, used for writes and removal
configLocked bool // global file present and opted into lockdown (global_lockdown: true)
eventLogDir string eventLogDir string
sandboxProfileDir string sandboxProfileDir string
sandboxViolationCacheDir string sandboxViolationCacheDir string
@@ -245,6 +245,15 @@ func (r *RuntimeConfig) IsManaged() bool {
return r.configFilePath != r.userConfigFilePath return r.configFilePath != r.userConfigFilePath
} }
// IsLocked reports whether a globally managed config opted into lockdown via
// global_lockdown: true. When locked, env and CLI overrides of config are
// refused. An unlocked managed config is an overridable baseline: it stays the
// authoritative file (the per-user file is still ignored), but env and CLI args
// can override its values at runtime.
func (r *RuntimeConfig) IsLocked() bool {
return r.configLocked
}
// EventLogDir returns the path to the event log directory. // EventLogDir returns the path to the event log directory.
func (r *RuntimeConfig) EventLogDir() string { func (r *RuntimeConfig) EventLogDir() string {
return r.eventLogDir return r.eventLogDir
@@ -296,12 +305,7 @@ type SandboxAllowOverride struct {
// The config package return an appropriate RuntimeConfig based on the environment and the configuration. // The config package return an appropriate RuntimeConfig based on the environment and the configuration.
func DefaultConfig() RuntimeConfig { func DefaultConfig() RuntimeConfig {
// Backward compatibility for the insecure installation flag before config was introduced. // Backward compatibility for the insecure installation flag before config was introduced.
insecureInstallation := false insecureInstallation := utils.EnvBool(pmgInsecureInstallationEnvKey, false)
if val := os.Getenv(pmgInsecureInstallationEnvKey); val != "" {
if boolVal, err := strconv.ParseBool(val); err == nil {
insecureInstallation = boolVal
}
}
return RuntimeConfig{ return RuntimeConfig{
Config: Config{ Config: Config{
@@ -401,6 +405,17 @@ func initConfig() {
globalConfig.sandboxProfileDir = sandboxProfileDir globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
// A globally managed config enforces lockdown only when it opts in via
// global_lockdown, read straight from the file so it cannot be flipped by
// env or CLI.
globalConfig.configLocked = globalConfig.IsManaged() && globalConfigEnablesLockdown(globalConfigFilePath())
// When locked, env cannot bypass the config, including the
// PMG_INSECURE_INSTALLATION malicious-package block bypass.
if globalConfig.IsLocked() {
globalConfig.InsecureInstallation = false
}
loadConfig() loadConfig()
if err := preprocessTrustedPackages(&globalConfig.Config); err != nil { if err := preprocessTrustedPackages(&globalConfig.Config); err != nil {
@@ -655,10 +670,15 @@ func RemoveUserConfigFile() error {
// globally managed configuration. It carries a useful error code and help text // globally managed configuration. It carries a useful error code and help text
// so the CLI presents it as an expected, actionable failure rather than a bug. // so the CLI presents it as an expected, actionable failure rather than a bug.
func NewManagedConfigError() error { func NewManagedConfigError() error {
msg := fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath) return managedError(fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath))
}
// managedError builds the standard "globally managed" CLI error with a useful
// code and actionable help.
func managedError(message string) error {
return usefulerror.Useful(). return usefulerror.Useful().
WithCode(usefulerror.ErrCodePermissionDenied). WithCode(usefulerror.ErrCodePermissionDenied).
WithHumanError(msg). WithHumanError(message).
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it."). WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
Wrap(errors.New(msg)) Wrap(errors.New(message))
} }
+87
View File
@@ -85,6 +85,93 @@ func TestSetConfigValueRefusedWhenManaged(t *testing.T) {
assert.NoFileExists(t, filepath.Join(userDir, "config.yml")) assert.NoFileExists(t, filepath.Join(userDir, "config.yml"))
} }
func TestEnvDoesNotOverrideLockedConfig(t *testing.T) {
globalDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\nglobal_lockdown: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
t.Setenv("PMG_PARANOID", "false")
initConfig()
require.True(t, Get().IsLocked())
assert.True(t, Get().Config.Paranoid, "PMG_PARANOID must not override a locked config")
}
func TestEnvOverridesManagedConfigWhenNotLocked(t *testing.T) {
globalDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
t.Setenv("PMG_PARANOID", "false")
initConfig()
require.True(t, Get().IsManaged())
require.False(t, Get().IsLocked())
assert.False(t, Get().Config.Paranoid, "without lockdown, PMG_PARANOID overrides the managed baseline")
}
func TestEnvOverridesUserConfigWhenNotManaged(t *testing.T) {
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, t.TempDir()) // empty global dir -> not managed
t.Setenv("PMG_CONFIG_DIR", userDir)
t.Setenv("PMG_PARANOID", "false")
initConfig()
require.False(t, Get().IsManaged())
assert.False(t, Get().Config.Paranoid, "PMG_PARANOID should override the per-user config")
}
func TestInsecureInstallationEnvIgnoredWhenLocked(t *testing.T) {
globalDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("global_lockdown: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
initConfig()
require.True(t, Get().IsLocked())
assert.False(t, Get().InsecureInstallation, "PMG_INSECURE_INSTALLATION must not bypass a locked config")
}
func TestInsecureInstallationHonoredWhenManagedNotLocked(t *testing.T) {
globalDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
initConfig()
require.True(t, Get().IsManaged())
require.False(t, Get().IsLocked())
assert.True(t, Get().InsecureInstallation, "without lockdown, PMG_INSECURE_INSTALLATION is honored")
}
func TestInsecureInstallationEnvHonoredWhenNotManaged(t *testing.T) {
useManagedConfigDir(t, t.TempDir()) // empty global dir -> not managed
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
initConfig()
require.False(t, Get().IsManaged())
assert.True(t, Get().InsecureInstallation)
}
func TestMalformedGlobalConfigFailsClosed(t *testing.T) {
globalDir := t.TempDir()
// Present but unparseable YAML ("mapping values not allowed in this context").
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("a: b: c\n"), 0o644))
useManagedConfigDir(t, globalDir)
initConfig()
require.True(t, Get().IsManaged())
assert.True(t, Get().IsLocked(), "a present but unparseable global config must fail closed (locked)")
}
func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) { func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
globalDir := t.TempDir() globalDir := t.TempDir()
userDir := t.TempDir() userDir := t.TempDir()
+58 -11
View File
@@ -5,12 +5,15 @@ import (
"os" "os"
"strings" "strings"
"github.com/safedep/dry/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// loadViperConfig loads the configuration using Viper. // loadViperConfig loads the configuration using Viper.
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults. // Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
// When a globally managed config is active, env overrides are disabled and
// managed flags are rejected, so the managed file is authoritative.
// Cobra flags write directly to the config struct after this function runs. // Cobra flags write directly to the config struct after this function runs.
func loadViperConfig() error { func loadViperConfig() error {
// Use the active config path resolved by initConfig (globally managed file // Use the active config path resolved by initConfig (globally managed file
@@ -19,12 +22,18 @@ func loadViperConfig() error {
v := viper.New() v := viper.New()
v.SetConfigType("yaml") v.SetConfigType("yaml")
v.SetEnvPrefix("PMG")
v.AutomaticEnv() // A locked global config must not be bypassable via PMG_* env vars, so
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) // AutomaticEnv is enabled unless lockdown is in force. An unlocked managed
// config stays an overridable baseline.
if !globalConfig.IsLocked() {
v.SetEnvPrefix("PMG")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
}
// Load the embedded template as the base so Viper knows all keys and their // 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 // defaults, and (when env overrides are enabled) can resolve PMG_* vars for
// keys that are absent from or newer than the user's config file. // keys that are absent from or newer than the user's config file.
if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil { if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil {
return fmt.Errorf("failed to load default config: %w", err) return fmt.Errorf("failed to load default config: %w", err)
@@ -56,17 +65,26 @@ func loadViperConfig() error {
return nil return nil
} }
// hasProxySectionInFile checks whether the user's config file contains a // readConfigFileKeys reads path and returns its top-level YAML mapping.
// top-level "proxy" key. Returns false if the file doesn't exist or can't func readConfigFileKeys(path string) (map[string]any, error) {
// be parsed.
func hasProxySectionInFile(path string) bool {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return false return nil, err
} }
var raw map[string]any var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil { if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
return raw, nil
}
// hasProxySectionInFile checks whether the config file at path contains a
// top-level "proxy" key. A missing or unparseable file reports false.
func hasProxySectionInFile(path string) bool {
raw, err := readConfigFileKeys(path)
if err != nil {
return false return false
} }
@@ -74,18 +92,47 @@ func hasProxySectionInFile(path string) bool {
return ok return ok
} }
// globalConfigEnablesLockdown reports whether the global config file at path
// enables lockdown. It is only called when a global config is present, so a read
// or parse failure means a managed file we cannot interpret: fail closed
// (locked) rather than silently dropping policy. global_lockdown is read directly
// from the file, so it cannot be flipped via env or CLI.
func globalConfigEnablesLockdown(path string) bool {
raw, err := readConfigFileKeys(path)
if err != nil {
log.Warnf("could not read global config %q to determine lockdown (%v); defaulting to locked", path, err)
return true
}
value, ok := raw["global_lockdown"]
if !ok {
return false
}
enabled, isBool := value.(bool)
if !isBool {
log.Warnf("config %q sets global_lockdown to a non-boolean value (%v); treating as disabled", path, value)
return false
}
return enabled
}
// applyProxyLegacyFallback populates the new Proxy struct from deprecated // applyProxyLegacyFallback populates the new Proxy struct from deprecated
// flat keys when the user's config file does not have a proxy: section. // 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 // New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence
// over legacy config file keys to respect the documented precedence order. // over legacy config file keys to respect the documented precedence order.
func applyProxyLegacyFallback(v *viper.Viper) { func applyProxyLegacyFallback(v *viper.Viper) {
if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") { // A locked config ignores env, so env must not suppress the legacy migration.
envIgnored := globalConfig.IsLocked()
if (envIgnored || os.Getenv("PMG_PROXY_ENABLED") == "") && v.IsSet("proxy_mode") {
val := v.GetBool("proxy_mode") val := v.GetBool("proxy_mode")
globalConfig.Config.Proxy.Enabled = val globalConfig.Config.Proxy.Enabled = val
v.Set("proxy.enabled", val) v.Set("proxy.enabled", val)
} }
if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") { if (envIgnored || os.Getenv("PMG_PROXY_INSTALL_ONLY") == "") && v.IsSet("proxy_install_only") {
val := v.GetBool("proxy_install_only") val := v.GetBool("proxy_install_only")
globalConfig.Config.Proxy.InstallOnly = val globalConfig.Config.Proxy.InstallOnly = val
v.Set("proxy.install_only", val) v.Set("proxy.install_only", val)
+61
View File
@@ -72,6 +72,8 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express
3. Config file (`config.yml`) 3. Config file (`config.yml`)
4. Built-in defaults 4. Built-in defaults
Under a [globally managed config](#globally-managed-configuration) with `global_lockdown` enabled, PMG disables `PMG_*` and managed-flag overrides.
**Limitation** **Limitation**
@@ -79,3 +81,62 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express
If a key is commented out (e.g. `# endpoint_id: "my-machine"`) or missing entirely, `set` will If a key is commented out (e.g. `# endpoint_id: "my-machine"`) or missing entirely, `set` will
return a "key not found" error. To fix this, uncomment or add the key manually via `pmg config edit`, return a "key not found" error. To fix this, uncomment or add the key manually via `pmg config edit`,
or run `pmg setup install` to merge missing template keys into your config. or run `pmg setup install` to merge missing template keys into your config.
## Globally Managed Configuration
For centrally managed or fleet deployments, PMG can read an OS-level **global config file**. When this file exists, it is authoritative: PMG uses it and ignores the per-user `config.yml` (the two are never merged). An administrator ships a machine-wide baseline this way, and can lock it (see [Lockdown](#lockdown)) to forbid user overrides.
**Paths** (used when the file is present):
| OS | Global config path |
|---|---|
| macOS | `/Library/Application Support/safedep/pmg/config.yml` |
| Linux | `/etc/safedep/pmg/config.yml` |
| Windows | `%PROGRAMDATA%\safedep\pmg\config.yml` |
Check whether a global config is active with `pmg setup info`:
```bash
pmg setup info
# Config Source: global <- global config, overrides allowed
# Config Source: global (locked) <- global config with lockdown enabled
# Config Source: user <- per-user config in effect
```
### Behaviour
Whenever a global config file is present:
- **It is authoritative.** PMG ignores the per-user `config.yml`. The file may be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to a user's values.
- **`config set` and `config edit` fail.** They return an error stating the config is globally managed. To change it, deploy an updated file at the OS path, which is root-owned and not writable by users.
- **`pmg setup install` skips the per-user config.** It still creates shell aliases and shims per user.
By default a user can still override the global config's values at runtime through `PMG_*` environment variables and CLI flags. Enable lockdown to forbid that.
### Lockdown
Add `global_lockdown: true` to the global config to enforce it:
```yaml
# Only meaningful in the global config file.
global_lockdown: true
```
When lockdown is on:
- **CLI flags that would change a managed value fail fast.** For example, `pmg --sandbox=false ...` or `pmg --paranoid ...` errors out instead of overriding policy. Governed flags: `--transitive`, `--transitive-depth`, `--include-dev-dependencies`, `--paranoid`, `--skip-event-log`, `--proxy-mode`, `--sandbox`, `--sandbox-enforce`, `--sandbox-profile`, `--sandbox-allow`, `--skip-dependency-cooldown`. Operational flags such as `--dry-run` keep working.
- **`PMG_*` variables cannot change the config**, including `PMG_INSECURE_INSTALLATION` (which otherwise bypasses malicious-package blocking).
PMG reads `global_lockdown` straight from the global file, so a user cannot flip it through env or CLI. If the global file exists but cannot be read or parsed, PMG fails closed and treats it as locked. `PMG_CONFIG_DIR` and `PMG_CACHE_DIR` still relocate per-user state directories (logs, cache) in any mode, but leave the managed config alone.
### Precedence
| Mode | Effective order (highest to lowest) |
|---|---|
| No global config | CLI flags > `PMG_*` env > per-user `config.yml` > built-in defaults |
| Global config, no lockdown | CLI flags > `PMG_*` env > global config > built-in defaults |
| Global config, `global_lockdown: true` | global config > built-in defaults (env and managed-flag overrides refused) |
### Deploying via MDM (macOS)
Scripts to install or update PMG and deploy a global config across a macOS fleet (Jamf, Mosyle, Kandji, Intune) live in [`scripts/mdm`](../scripts/mdm). Bundle a `config.yml` next to the scripts. The installer places it at the global path, and the uninstaller removes it. See the [`scripts/mdm` README](../scripts/mdm/README.md) for details.
+7
View File
@@ -83,6 +83,13 @@ func main() {
log.InitZapLogger("pmg", "cli") log.InitZapLogger("pmg", "cli")
// Refuse flags that would override a globally managed config before any
// config-dependent initialization (event logging, audit) runs, so a
// managed flag like --skip-event-log cannot take effect first.
if err := config.RejectManagedFlagOverrides(cmd); err != nil {
ui.ErrorExit(err)
}
// Initialize event logging (silently fail if it can't be initialized) // Initialize event logging (silently fail if it can't be initialized)
var eventlogErr error var eventlogErr error
if logFile != "" { if logFile != "" {
+2 -1
View File
@@ -58,8 +58,9 @@ It then removes the machine-wide binary via `brew uninstall`, or by deleting `/u
## Globally managed config ## Globally managed config
Include a `config.yml` next to the scripts to centrally manage PMG configuration. When that file is present at `/Library/Application Support/safedep/pmg/config.yml`, PMG treats it as authoritative and **ignores every user's own config**. Users cannot override it: `pmg config set` and `pmg config edit` refuse, and the file is root-owned (`0644`), so it is not user-writable. Include a `config.yml` next to the scripts to centrally manage PMG configuration. When that file is present at `/Library/Application Support/safedep/pmg/config.yml`, PMG treats it as authoritative and **ignores every user's own config**. `pmg config set` and `pmg config edit` refuse, and the file is root-owned (`0644`), so it is not user-writable.
- By default the global config is an overridable baseline: users can still override its values at runtime with `PMG_*` env vars and CLI flags. Set `global_lockdown: true` in the bundled `config.yml` to forbid those overrides. See [Globally Managed Configuration](../../docs/config.md#globally-managed-configuration) for the full behaviour.
- The file can be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to user values. - The file can be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to user values.
- To enable cloud sync, set `cloud.enabled: true` in the bundled `config.yml`. The install script skips the per-user `pmg config set` (a managed config refuses it) but still stores each logged-in user's credentials in the Keychain. - To enable cloud sync, set `cloud.enabled: true` in the bundled `config.yml`. The install script skips the per-user `pmg config set` (a managed config refuses it) but still stores each logged-in user's credentials in the Keychain.
- Install copies the bundled `config.yml` to the global path *before* configuring users, so each user's setup skips writing a per-user config. - Install copies the bundled `config.yml` to the global path *before* configuring users, so each user's setup skips writing a per-user config.