2025-05-15 16:50:59 +05:30
package config
import (
2026-05-21 14:32:30 +05:30
"errors"
2025-05-15 16:50:59 +05:30
"fmt"
2026-01-01 12:33:52 +05:30
"os"
2026-07-14 21:34:22 +05:30
"os/user"
2026-01-01 12:33:52 +05:30
"path/filepath"
"runtime"
2026-05-20 13:56:50 +05:30
"time"
2026-01-01 12:33:52 +05:30
_ "embed"
2026-01-08 00:15:02 +05:30
2026-07-14 21:34:22 +05:30
"github.com/safedep/pmg/internal/fsutil"
2026-01-08 00:15:02 +05:30
"github.com/safedep/dry/log"
2026-05-24 12:22:19 +05:30
"github.com/safedep/dry/usefulerror"
2026-05-24 12:46:06 +05:30
"github.com/safedep/dry/utils"
2026-05-24 12:22:19 +05:30
"github.com/safedep/pmg/errcodes"
2026-05-16 09:55:54 +05:30
"github.com/spf13/viper"
2025-05-15 16:50:59 +05:30
)
2026-01-01 12:33:52 +05:30
const (
2026-03-04 13:32:34 +05:30
// Verbosity level constants for the config file
VerbositySilent = "silent"
VerbosityNormal = "normal"
VerbosityVerbose = "verbose"
2026-01-01 12:33:52 +05:30
// Environment variable key for the insecure installation flag
2026-02-02 12:58:28 +05:30
pmgInsecureInstallationEnvKey = "PMG_INSECURE_INSTALLATION"
2026-01-01 12:33:52 +05:30
// Allow overriding the config path from the environment
2026-02-02 12:58:28 +05:30
pmgConfigDirEnvKey = "PMG_CONFIG_DIR"
2026-01-01 12:33:52 +05:30
2026-05-19 14:40:54 +05:30
// Allow overriding the cache path from the environment
pmgCacheDirEnvKey = "PMG_CACHE_DIR"
2026-01-01 12:33:52 +05:30
// Config path is computed as the user config directory + the default relative path
// when not overridden by the environment variable
2026-02-02 12:58:28 +05:30
pmgDefaultHomeRelativePath = "safedep/pmg"
2026-01-01 12:33:52 +05:30
// Default log directory is relative to the config directory.
2026-02-02 12:58:28 +05:30
pmgDefaultLogDir = "logs"
2026-01-01 12:33:52 +05:30
2026-05-19 14:40:54 +05:30
// Default sandbox profile directory is relative to the config directory.
pmgDefaultSandboxProfileDir = "sandbox/profiles"
2026-05-26 21:06:57 +05:30
// Default sandbox overlay directory is relative to the config directory.
// Per-repo overlays persisted by `pmg sandbox allow` live here.
pmgDefaultSandboxOverlayDir = "sandbox/overlays"
2026-07-21 15:14:07 +05:30
// Default sandbox preset directory is relative to the config directory.
// User/community preset YAML files live here.
pmgDefaultSandboxPresetDir = "sandbox/presets"
2026-05-19 14:40:54 +05:30
// Default sandbox violation cache directory is relative to the cache root.
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
2026-06-22 10:12:02 +05:30
// Default localdb directory is relative to the cache root.
pmgDefaultLocalDBDir = "localdb"
// Default localdb file name for PMG's shared SQLite database.
pmgDefaultLocalDBFileName = "pmg.db"
2026-01-01 12:33:52 +05:30
// Config file name.
// Important: The config file path and the schema should be backward compatible. In case of breaking config
// changes, we must introduce a new file name and a migration path.
2026-02-02 12:58:28 +05:30
pmgConfigFileName = "config.yml"
2026-01-01 12:33:52 +05:30
)
//go:embed config.template.yml
var templateConfig string
// Config is the global configuration for PMG that can be persisted or loaded from a given source.
// Here we only define the configuration that can be persisted or loaded from a given source and
// not those that we believe should not be persisted (eg. insecure installation, etc.)
type Config struct {
2026-02-10 20:38:55 +05:30
// Paranoid enables high-security defaults (e.g., treating suspicious behavior as malicious).
2026-01-01 12:33:52 +05:30
Paranoid bool `mapstructure:"paranoid"`
2026-04-22 22:04:56 +05:30
// DisableTelemetry allows turning off telemetry collection.
DisableTelemetry bool `mapstructure:"disable_telemetry"`
2026-01-01 12:33:52 +05:30
// TrustedPackages allows for trusting a suspicious package and ignoring the suspicious behaviour for the package in future installations
TrustedPackages [] TrustedPackage `mapstructure:"trusted_packages"`
2026-07-09 17:49:00 +05:30
// AdvisoryMessage is an optional org-specific message appended to every
// block output, regardless of which control blocked the installation.
AdvisoryMessage string `mapstructure:"advisory_message"`
2026-01-01 12:33:52 +05:30
// SkipEventLogging allows for skipping event logging.
SkipEventLogging bool `mapstructure:"skip_event_logging"`
// EventLogRetentionDays is the number of days to retain event logs.
EventLogRetentionDays int `mapstructure:"event_log_retention_days"`
2026-01-07 13:22:08 +05:30
2026-05-06 18:27:23 +05:30
// Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files.
2026-04-17 01:13:30 +05:30
ProxyInstallOnly bool `mapstructure:"proxy_install_only"`
2026-03-04 13:32:34 +05:30
// Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
Verbosity string `mapstructure:"verbosity"`
2026-01-13 14:52:02 +05:30
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
Sandbox SandboxConfig `mapstructure:"sandbox"`
2026-04-08 21:04:17 +05:30
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
2026-04-11 12:33:27 +05:30
2026-06-17 15:07:47 +05:30
// AnalysisCache configures the optional cross-run cache of malware-analysis
// verdicts, so repeat installs of an already-screened dependency graph skip
// the per-package analysis round-trip.
AnalysisCache AnalysisCacheConfig `mapstructure:"analysis_cache"`
2026-04-11 12:33:27 +05:30
Cloud CloudConfig `mapstructure:"cloud"`
2026-05-06 18:27:23 +05:30
Proxy ProxyConfig `mapstructure:"proxy"`
2026-04-11 12:33:27 +05:30
}
2026-06-17 15:07:47 +05:30
// AnalysisCacheConfig is the umbrella for per-analyzer cross-run caches. Caching
// is analyzer-specific — each analyzer decides what is safe to cache — so config
// is nested per analyzer rather than shared. Today only the Malysis (malware)
// analyzer has a cache; future analyzers can add their own sub-config here.
type AnalysisCacheConfig struct {
// Malysis configures the cross-run cache for the Malysis malware analyzer.
Malysis MalysisCacheConfig `mapstructure:"malysis"`
}
// MalysisCacheConfig configures a persistent, cross-run cache of package
// malware-analysis verdicts produced by the Malysis analyzer.
//
// By default PMG keeps an in-memory analysis cache that lives only for the
// duration of a single invocation, so every install re-screens the whole
// resolved graph against the analysis backend. When Enabled, clean (ALLOW)
// verdicts are additionally persisted on disk and reused across runs, which
// makes repeat installs of an unchanged graph fast.
//
// Security trade-off: a version that was clean when first screened but is later
// flagged as malicious is served from cache (and thus allowed) until its entry
// expires; TTL bounds that exposure window. Only ALLOW verdicts are cached —
// suspicious, malicious, and tenant-excluded verdicts are always re-evaluated.
// Disabled by default.
type MalysisCacheConfig struct {
Enabled bool `mapstructure:"enabled"`
// TTL is how long a cached verdict remains valid. A non-positive TTL
// disables persistence (entries are always treated as a miss).
TTL time . Duration `mapstructure:"ttl"`
}
2026-04-11 12:33:27 +05:30
// CloudConfig configures audit event sync to SafeDep Cloud.
type CloudConfig struct {
2026-05-20 13:56:50 +05:30
Enabled bool `mapstructure:"enabled"`
EndpointID string `mapstructure:"endpoint_id"`
AutoSync CloudAutoSyncConfig `mapstructure:"auto_sync"`
}
// CloudAutoSyncConfig controls opportunistic background sync of the cloud
// audit WAL. When Enabled, PMG spawns a detached `pmg cloud sync-background`
// child at the end of each invocation, gated by a per-host cooldown so the
// sync does not fire on every command.
type CloudAutoSyncConfig struct {
Enabled bool `mapstructure:"enabled"`
MinInterval time . Duration `mapstructure:"min_interval"`
Timeout time . Duration `mapstructure:"timeout"`
2026-01-13 14:52:02 +05:30
}
2026-05-06 18:27:23 +05:30
type ProxyConfig struct {
2026-05-06 19:26:39 +05:30
InstallOnly bool `mapstructure:"install_only"`
SkipCommands map [ string ][] string `mapstructure:"skip_commands"`
2026-06-26 11:19:28 +05:30
Server ProxyServerConfig `mapstructure:"server"`
}
// ProxyServerConfig configures the persistent proxy server (`pmg proxy start`).
type ProxyServerConfig struct {
// ListenHost is the host the persistent proxy binds to. Defaults to
// 127.0.0.1 (loopback). Set to 0.0.0.0 or a specific interface only for a
// deliberately hosted deployment: a non-loopback bind exposes the MITM
// proxy to the network. The --host flag overrides this.
ListenHost string `mapstructure:"listen_host"`
// ListenPort is the port the persistent proxy binds to. 0 (default) means a
// random free port. The --port flag overrides this.
ListenPort int `mapstructure:"listen_port"`
2026-05-06 18:27:23 +05:30
}
2026-01-13 14:52:02 +05:30
// SandboxConfig configures the sandbox system for isolating package manager processes.
type SandboxConfig struct {
// Enabled enables sandbox mode (opt-in by default for backward compatibility).
Enabled bool `mapstructure:"enabled"`
2026-01-19 22:24:59 +05:30
// EnforceAlways controls scope of sandbox enforcement:
// - When true: sandbox applies to all package manager commands
// - When false: sandbox only applies to install commands, others run unrestricted (default)
EnforceAlways bool `mapstructure:"enforce_always"`
2026-01-13 14:52:02 +05:30
// Policies maps package manager names to their sandbox policy references.
// Key is package manager name (e.g., "npm", "pip"), value is policy reference.
Policies map [ string ] SandboxPolicyRef `mapstructure:"policies"`
// PolicyTemplates maps template names to their paths.
PolicyTemplates map [ string ] SandboxPolicyTemplate `mapstructure:"policy_templates"`
}
2026-04-08 21:04:17 +05:30
// DependencyCooldownConfig blocks installation of package versions published within a
// configurable time window, reducing exposure to supply chain attacks.
type DependencyCooldownConfig struct {
Enabled bool `mapstructure:"enabled"`
Days int `mapstructure:"days"`
2026-06-15 19:44:55 +05:30
// Skip is a per-control skip list of packages exempt from the cooldown
2026-06-21 18:22:15 +05:30
// window. Unlike the top-level trusted_packages (which waives every PMG
// control — malware analysis, cooldown, and any future controls — and is
// already honored here), an entry on this list waives ONLY the cooldown
// wait, never malware analysis, so a fast-tracked package is still
// scanned. Intended for first-party / internal packages that must be
2026-06-15 19:44:55 +05:30
// installed immediately on release.
//
// Matching: a PURL without a version skips cooldown for ALL versions of the
// package (package-level); a PURL with a version skips cooldown for that
// version only (version-level).
Skip [] TrustedPackage `mapstructure:"skip"`
2026-04-08 21:04:17 +05:30
}
2026-06-11 11:40:33 +05:30
// legacyProfileAliases maps old default profile names, keyed by package
// manager, to their per-PM leaf profiles. When npm-restrictive and
// pypi-restrictive became pure bases with no environment allows (and
// pnpm-restrictive was renamed to pnpm), existing config files kept the old
// mappings (config merge preserves user values), so the old defaults are
// re-mapped at read time.
var legacyProfileAliases = map [ string ] map [ string ] string {
"npm-restrictive" : {
"npm" : "npm" ,
"yarn" : "yarn" ,
"bun" : "bun" ,
},
"pnpm-restrictive" : {
"pnpm" : "pnpm" ,
},
"pypi-restrictive" : {
"pip" : "pip" ,
"pip3" : "pip" ,
"pipx" : "pipx" ,
"poetry" : "poetry" ,
"uv" : "uv" ,
2026-07-02 18:49:31 +05:30
"uvx" : "uvx" ,
2026-06-11 11:40:33 +05:30
},
}
// PolicyFor returns the sandbox policy reference for a package manager,
// re-mapping legacy default profiles to their per-PM leaf profiles. The
// re-mapping is skipped when a policy template overrides the legacy name,
// since the user's custom template must keep winning as it did before the
// profile split.
func ( s * SandboxConfig ) PolicyFor ( pmName string ) ( SandboxPolicyRef , bool ) {
ref , exists := s . Policies [ pmName ]
if ! exists {
return SandboxPolicyRef {}, false
}
if leaves , legacy := legacyProfileAliases [ ref . Profile ]; legacy {
if _ , overridden := s . PolicyTemplates [ ref . Profile ]; ! overridden {
if leaf , ok := leaves [ pmName ]; ok {
ref . Profile = leaf
}
}
}
return ref , true
}
2026-01-13 14:52:02 +05:30
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
// a profile name to a path.
type SandboxPolicyTemplate struct {
// Path is the path to the template file.
// Relative path can be used to reference a template file in the config directory (example: ./npm-restrictive.yml)
Path string `mapstructure:"path"`
}
// SandboxPolicyRef references a sandbox policy for a specific package manager.
type SandboxPolicyRef struct {
// Enabled enables sandboxing for this specific package manager.
Enabled bool `mapstructure:"enabled"`
// Profile is the name of a built-in profile (e.g., "npm-restrictive")
// or an absolute path to a custom YAML policy file.
Profile string `mapstructure:"profile"`
2025-05-15 16:50:59 +05:30
}
2026-01-01 12:33:52 +05:30
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
type TrustedPackage struct {
Purl string `mapstructure:"purl"`
Reason string `mapstructure:"reason"`
2026-01-08 00:15:02 +05:30
2026-07-09 17:49:00 +05:30
purlRef
2026-01-01 12:33:52 +05:30
}
2025-05-16 19:38:06 +05:30
2026-01-01 12:33:52 +05:30
// RuntimeConfig is the configuration that is used at runtime. It contains static configuration
// that can be loaded from a source and, if allowed, overridden by the user at runtime.
type RuntimeConfig struct {
Config Config
// DryRun enables dry-run mode for the package manager, where actual execution of commands is skipped.
2025-05-16 19:38:06 +05:30
DryRun bool
2025-07-02 19:09:27 +05:30
// InsecureInstallation allows bypassing install blocking on malicious packages
InsecureInstallation bool
2026-01-01 12:33:52 +05:30
2026-01-13 14:52:02 +05:30
// SandboxProfileOverride is a runtime override for the sandbox policy profile.
// When set, this profile path is used instead of the configured policy for all package managers.
// This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml.
SandboxProfileOverride string
2026-02-16 13:58:08 +05:30
// SandboxAllowOverrides holds runtime sandbox allow rules from --sandbox-allow flags.
// These are additive rules applied on top of the resolved sandbox policy.
// Not persisted to config.yml.
SandboxAllowOverrides [] SandboxAllowOverride
2026-01-01 12:33:52 +05:30
// Internal config values computed at runtime and must be accessed via. API
2026-05-19 14:40:54 +05:30
configDir string
2026-05-21 14:32:30 +05:30
configFilePath string // active config: globally managed file if present, else per-user
userConfigFilePath string // per-user config file, used for writes and removal
2026-05-21 16:37:26 +05:30
configLocked bool // global file present and opted into lockdown (global_lockdown: true)
2026-05-19 14:40:54 +05:30
eventLogDir string
sandboxProfileDir string
2026-05-26 21:06:57 +05:30
sandboxOverlayDir string
2026-07-21 15:14:07 +05:30
sandboxPresetDir string
2026-05-19 14:40:54 +05:30
sandboxViolationCacheDir string
2026-06-22 10:12:02 +05:30
localDBDir string
2026-06-17 15:07:47 +05:30
cacheDir string
2026-05-19 14:40:54 +05:30
viper * viper . Viper
2025-05-15 16:50:59 +05:30
}
2026-04-11 12:33:27 +05:30
// CloudSyncDBPath returns the path to the cloud sync WAL database.
func ( r * RuntimeConfig ) CloudSyncDBPath () string {
return filepath . Join ( r . configDir , "cloud-sync.db" )
}
2026-05-20 13:56:50 +05:30
// CloudSyncLockPath returns the path to the cross-process lock file that
// serializes manual `pmg cloud sync` and the auto-sync background child.
func ( r * RuntimeConfig ) CloudSyncLockPath () string {
return filepath . Join ( r . configDir , "cloud-sync.lock" )
}
// CloudSyncLastRunPath returns the path to the timestamp file recording the
// last sync attempt (success or failure) in Unix epoch seconds.
func ( r * RuntimeConfig ) CloudSyncLastRunPath () string {
return filepath . Join ( r . configDir , "cloud-sync.lastrun" )
}
2026-05-21 14:32:30 +05:30
// ConfigFilePath returns the path to the active config file (the globally
// managed file when present, otherwise the per-user file).
2026-01-01 12:33:52 +05:30
func ( r * RuntimeConfig ) ConfigFilePath () string {
return r . configFilePath
2025-05-15 16:50:59 +05:30
}
2026-05-21 14:32:30 +05:30
// UserConfigFilePath returns the per-user config file path, regardless of
// whether a globally managed config is active.
func ( r * RuntimeConfig ) UserConfigFilePath () string {
return r . userConfigFilePath
}
// IsManaged reports whether the active config is the globally managed file.
// When true, the per-user file is ignored and config writes are refused. It is
// derived: the active path differs from the per-user path only when the global
// file was chosen.
func ( r * RuntimeConfig ) IsManaged () bool {
return r . configFilePath != r . userConfigFilePath
}
2026-05-21 16:37:26 +05:30
// 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
}
2026-01-01 12:33:52 +05:30
// EventLogDir returns the path to the event log directory.
func ( r * RuntimeConfig ) EventLogDir () string {
return r . eventLogDir
}
2026-01-13 14:52:02 +05:30
// ConfigDir returns the path to the config directory.
func ( r * RuntimeConfig ) ConfigDir () string {
return r . configDir
}
2026-05-19 14:40:54 +05:30
// SandboxProfileDir returns the path to the user sandbox profile directory.
func ( r * RuntimeConfig ) SandboxProfileDir () string {
return r . sandboxProfileDir
}
2026-05-26 21:06:57 +05:30
// SandboxOverlayDir returns the path to the per-repo sandbox overlay directory.
func ( r * RuntimeConfig ) SandboxOverlayDir () string {
return r . sandboxOverlayDir
}
2026-07-21 15:14:07 +05:30
// SandboxPresetDir returns the path to the user sandbox preset directory.
func ( r * RuntimeConfig ) SandboxPresetDir () string {
return r . sandboxPresetDir
}
2026-05-19 14:40:54 +05:30
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
func ( r * RuntimeConfig ) SandboxViolationCacheDir () string {
return r . sandboxViolationCacheDir
}
2026-06-17 15:07:47 +05:30
// CacheDir returns the path to the PMG cache root directory. This follows the
// platform cache convention (XDG cache dir on Linux, ~/Library/Caches on macOS,
// %LOCALAPPDATA% on Windows) and is overridable via PMG_CACHE_DIR. Caching
// layers (e.g. the analysis cache) should store regenerable data here rather
// than under the config directory.
func ( r * RuntimeConfig ) CacheDir () string {
return r . cacheDir
}
2026-06-22 10:12:02 +05:30
// LocalDBDir returns the directory holding PMG's shared localdb SQLite file.
// localdb writes sibling -wal/-shm files here, so the Dir and FileName are
// exposed separately to match localdb.Config rather than as a joined path.
func ( r * RuntimeConfig ) LocalDBDir () string {
return r . localDBDir
}
// LocalDBFileName returns the file name of PMG's shared localdb SQLite file.
func ( r * RuntimeConfig ) LocalDBFileName () string {
return pmgDefaultLocalDBFileName
}
2026-02-16 13:58:08 +05:30
// SandboxAllowType represents the type of a sandbox allow override.
type SandboxAllowType string
const (
SandboxAllowRead SandboxAllowType = "read"
SandboxAllowWrite SandboxAllowType = "write"
SandboxAllowExec SandboxAllowType = "exec"
SandboxAllowNetConnect SandboxAllowType = "net-connect"
SandboxAllowNetBind SandboxAllowType = "net-bind"
2026-06-11 11:40:33 +05:30
SandboxAllowEnv SandboxAllowType = "env"
2026-07-21 15:14:07 +05:30
SandboxAllowPreset SandboxAllowType = "preset"
2026-02-16 13:58:08 +05:30
)
// SandboxAllowOverride represents a single --sandbox-allow flag value.
type SandboxAllowOverride struct {
2026-07-21 15:14:07 +05:30
// Type is the resource type (read, write, exec, net-connect, net-bind,
// env, preset).
2026-02-16 13:58:08 +05:30
Type SandboxAllowType
// Value is the resolved value (absolute path, host:port, etc.).
Value string
// Raw is the original CLI value before resolution (for logging/warnings).
Raw string
}
2026-01-01 12:33:52 +05:30
// DefaultConfig is a fail safe contract for the runtime configuration.
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
func DefaultConfig () RuntimeConfig {
// Backward compatibility for the insecure installation flag before config was introduced.
2026-05-21 16:37:26 +05:30
insecureInstallation := utils . EnvBool ( pmgInsecureInstallationEnvKey , false )
2025-05-15 16:50:59 +05:30
2026-01-01 12:33:52 +05:30
return RuntimeConfig {
Config : Config {
2026-07-22 15:19:19 +05:30
Paranoid : false ,
DisableTelemetry : false ,
EventLogRetentionDays : 7 ,
SkipEventLogging : false ,
TrustedPackages : [] TrustedPackage {},
AdvisoryMessage : "" ,
Verbosity : VerbosityNormal ,
2026-01-13 14:52:02 +05:30
Sandbox : SandboxConfig {
2026-01-19 22:24:59 +05:30
Enabled : false ,
EnforceAlways : false ,
2026-01-13 14:52:02 +05:30
},
2026-04-08 21:04:17 +05:30
DependencyCooldown : DependencyCooldownConfig {
Enabled : true ,
Days : 5 ,
},
2026-06-17 15:07:47 +05:30
AnalysisCache : AnalysisCacheConfig {
Malysis : MalysisCacheConfig {
Enabled : false ,
TTL : 24 * time . Hour ,
},
},
2026-04-11 12:33:27 +05:30
Cloud : CloudConfig {
Enabled : false ,
2026-05-20 13:56:50 +05:30
AutoSync : CloudAutoSyncConfig {
Enabled : true ,
MinInterval : 15 * time . Minute ,
Timeout : 5 * time . Minute ,
},
2026-04-11 12:33:27 +05:30
},
2026-05-06 18:27:23 +05:30
Proxy : ProxyConfig {
2026-05-24 12:46:06 +05:30
InstallOnly : false ,
2026-05-06 19:26:39 +05:30
SkipCommands : map [ string ][] string {},
2026-06-26 11:19:28 +05:30
Server : ProxyServerConfig {
ListenHost : "127.0.0.1" ,
},
2026-05-06 18:27:23 +05:30
},
2026-01-01 12:33:52 +05:30
},
DryRun : false ,
InsecureInstallation : insecureInstallation ,
}
}
// globalConfig is the global configuration for PMG.
// It is initialized in the init function and can be overridden by a repository.
var globalConfig * RuntimeConfig
func init () {
initConfig ()
}
2026-05-20 13:56:50 +05:30
// Reload re-runs the initialization that runs at package init. Tests that
// mutate PMG_CONFIG_DIR via t.Setenv must call this so the resolved config
// directory reflects the new env, instead of the value computed when the
// package was first loaded.
func Reload () {
initConfig ()
}
2026-01-01 12:33:52 +05:30
// initConfig should be idempotent and can be called multiple times.
// This is required for testing purposes.
func initConfig () {
defaultConfig := DefaultConfig ()
globalConfig = & defaultConfig
configDir , err := configDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get config directory: %w" , err ))
}
2026-05-21 14:32:30 +05:30
activeConfigPath , err := resolveConfigFile ()
2026-01-01 12:33:52 +05:30
if err != nil {
2026-05-21 14:32:30 +05:30
panic ( fmt . Errorf ( "failed to resolve config file path: %w" , err ))
}
userConfigPath , err := userConfigFilePath ()
if err != nil {
panic ( fmt . Errorf ( "failed to get user config file path: %w" , err ))
2026-01-01 12:33:52 +05:30
}
eventLogDir , err := eventLogDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get event log directory: %w" , err ))
}
2026-05-19 14:40:54 +05:30
sandboxProfileDir , err := sandboxProfileDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get sandbox profile directory: %w" , err ))
}
sandboxViolationCacheDir , err := sandboxViolationCacheDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get sandbox violation cache directory: %w" , err ))
}
2026-05-26 21:06:57 +05:30
sandboxOverlayDir , err := sandboxOverlayDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get sandbox overlay directory: %w" , err ))
}
2026-07-21 15:14:07 +05:30
sandboxPresetDir , err := sandboxPresetDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get sandbox preset directory: %w" , err ))
}
2026-06-17 15:07:47 +05:30
cacheRootDir , err := cacheDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get cache directory: %w" , err ))
}
2026-06-22 10:12:02 +05:30
localDBDir , err := localDBDir ()
if err != nil {
panic ( fmt . Errorf ( "failed to get localdb directory: %w" , err ))
}
2026-01-01 12:33:52 +05:30
globalConfig . configDir = configDir
2026-05-21 14:32:30 +05:30
globalConfig . configFilePath = activeConfigPath
globalConfig . userConfigFilePath = userConfigPath
2026-01-01 12:33:52 +05:30
globalConfig . eventLogDir = eventLogDir
2026-05-19 14:40:54 +05:30
globalConfig . sandboxProfileDir = sandboxProfileDir
2026-05-26 21:06:57 +05:30
globalConfig . sandboxOverlayDir = sandboxOverlayDir
2026-07-21 15:14:07 +05:30
globalConfig . sandboxPresetDir = sandboxPresetDir
2026-05-19 14:40:54 +05:30
globalConfig . sandboxViolationCacheDir = sandboxViolationCacheDir
2026-06-22 10:12:02 +05:30
globalConfig . localDBDir = localDBDir
2026-06-17 15:07:47 +05:30
globalConfig . cacheDir = cacheRootDir
2026-01-01 12:33:52 +05:30
2026-05-21 16:37:26 +05:30
// 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
}
2026-01-01 12:33:52 +05:30
loadConfig ()
2026-01-08 00:15:02 +05:30
2026-07-09 17:49:00 +05:30
if err := preprocessPackageRefs ( & globalConfig . Config ); err != nil {
log . Warnf ( "Failed to preprocess package refs: %v" , err )
2026-01-08 00:15:02 +05:30
}
2026-01-01 12:33:52 +05:30
}
// loadConfig loads the configuration from the config file.
// This is where we determine the source of config and use the appropriate loader.
2026-03-31 19:21:01 +05:30
// Right now we only support loading from a config file using Viper. If loading
// fails, the default configuration is used and a warning is logged.
2026-01-01 12:33:52 +05:30
func loadConfig () {
2026-03-31 19:21:01 +05:30
if err := loadViperConfig (); err != nil {
log . Warnf ( "Failed to load config, using defaults: %v" , err )
}
2026-01-01 12:33:52 +05:30
}
2026-07-14 21:34:22 +05:30
// configGeteuid is overridable in tests to exercise root path resolution
// without running as root.
var configGeteuid = os . Geteuid
// rootHomeDir returns root's home from the passwd database. Path resolution
// for root must not consult HOME or XDG_*: sudo and su can preserve the
// invoking user's environment (GitHub runners, sudo -E, su without -), which
// would make root create root-owned state inside that user's home and
// fail-close every later non-root pmg run for them.
func rootHomeDir () ( string , error ) {
u , err := user . LookupId ( "0" )
if err != nil {
return "" , fmt . Errorf ( "failed to resolve root home directory: %w" , err )
}
if u . HomeDir == "" {
return "" , fmt . Errorf ( "root user has no home directory" )
}
return u . HomeDir , nil
}
// rootConfigDir mirrors os.UserConfigDir platform conventions for root's
// passwd home.
func rootConfigDir () ( string , error ) {
home , err := rootHomeDir ()
if err != nil {
return "" , err
}
if runtime . GOOS == "darwin" {
return filepath . Join ( home , "Library" , "Application Support" ), nil
}
return filepath . Join ( home , ".config" ), nil
}
// rootCacheDir mirrors os.UserCacheDir platform conventions for root's
// passwd home.
func rootCacheDir () ( string , error ) {
home , err := rootHomeDir ()
if err != nil {
return "" , err
}
if runtime . GOOS == "darwin" {
return filepath . Join ( home , "Library" , "Caches" ), nil
}
return filepath . Join ( home , ".cache" ), nil
}
// Overridable in tests to exercise the passwd-unavailable fallback.
var (
rootConfigDirResolver = rootConfigDir
rootCacheDirResolver = rootCacheDir
)
// currentUserHomeDir returns the current user's home from the passwd
// database, ignoring HOME and XDG_* env vars that may be leaked from another
// account. Overridable in tests.
var currentUserHomeDir = func () ( string , error ) {
u , err := user . Current ()
if err != nil {
return "" , err
}
if u . HomeDir == "" {
return "" , fmt . Errorf ( "user %s has no home directory in the passwd database" , u . Username )
}
return u . HomeDir , nil
}
// unwritableDirCause is why the current user cannot write a per-user
// directory. The remedy must match the cause: chown-ing a directory that
// belongs to another account steals it and breaks that account, so chown is
// only safe for a directory inside the current user's passwd home, which a
// leaked environment cannot influence.
type unwritableDirCause int
const (
// causeExplicitConfigDir: PMG_CONFIG_DIR selected the directory.
causeExplicitConfigDir unwritableDirCause = iota
// causeLeakedHomeEnv: the directory is outside the current user's passwd
// home, so HOME or XDG_CONFIG_HOME leaked from another account (e.g.
// sudo -u on hosts that preserve the environment).
causeLeakedHomeEnv
// causeRootCreatedDir: the directory is inside the user's own home; a
// root or sudo run most likely created it root-owned.
causeRootCreatedDir
)
func classifyUnwritableDir ( dir string ) unwritableDirCause {
if os . Getenv ( pmgConfigDirEnvKey ) != "" {
return causeExplicitConfigDir
}
home , err := currentUserHomeDir ()
if err == nil && ! fsutil . PathWithinDir ( dir , home ) {
return causeLeakedHomeEnv
}
return causeRootCreatedDir
}
// UnwritableConfigDirRemedy renders the remedy for a per-user config or
// event-log directory the current user cannot write: help is the full
// explanation for fatal CLI errors, fix the terse variant for the doctor
// table. Cause diagnosis lives in classifyUnwritableDir.
func UnwritableConfigDirRemedy ( dir string ) ( help , fix string ) {
switch classifyUnwritableDir ( dir ) {
case causeExplicitConfigDir :
return fmt . Sprintf ( "PMG_CONFIG_DIR points at %s; make it writable by your user" , dir ),
"Make PMG_CONFIG_DIR writable"
case causeLeakedHomeEnv :
return fmt . Sprintf (
"pmg resolved its config directory to %s, outside your home: HOME or XDG_CONFIG_HOME leaked from another account (e.g. sudo -u). Fix the environment, e.g. export XDG_CONFIG_HOME=\"$HOME/.config\"" ,
dir ),
`Fix leaked env: export XDG_CONFIG_HOME="$HOME/.config"`
default :
chown := fmt . Sprintf ( "sudo chown -R $(id -un) %s" , dir )
return fmt . Sprintf ( "If a root or sudo run created it, restore ownership: %s" , chown ), chown
}
}
// isSudoElevation reports whether pmg is running as root via sudo, i.e. a
// non-root user elevated and sudo may have preserved that user's HOME/XDG_*.
// Only then do per-user paths divert to root's own home, so root does not
// create state inside the invoking user's home. Running genuinely as root
// (no sudo) keeps honoring HOME/XDG_*, which is legitimate and intended (e.g.
// golden Docker images that set HOME/XDG_CONFIG_HOME on purpose). This mirrors
// the SUDO_USER guard used elsewhere (cmd/setup/cert.go). su without sudo does
// not set SUDO_USER and is not covered; the unwritable-dir remedy still guides
// the user if such a run poisons a directory.
func isSudoElevation () bool {
return configGeteuid () == 0 && os . Getenv ( "SUDO_USER" ) != ""
}
2026-01-01 12:33:52 +05:30
// configDir computes the path to the config directory.
func configDir () ( string , error ) {
2026-02-02 12:58:28 +05:30
dir := os . Getenv ( pmgConfigDirEnvKey )
2026-01-01 12:33:52 +05:30
if dir != "" {
return dir , nil
}
2026-07-14 21:34:22 +05:30
if isSudoElevation () {
if base , err := rootConfigDirResolver (); err == nil {
return filepath . Join ( base , pmgDefaultHomeRelativePath ), nil
} else {
// No resolvable root passwd entry (e.g. scratch containers,
// minimal chroots). Fall back to env-based resolution: without a
// passwd database there is no user switching, so the cross-user
// poisoning this branch prevents cannot occur.
log . Warnf ( "failed to resolve root home for config dir, using environment: %v" , err )
}
}
2026-01-01 12:33:52 +05:30
userConfigDir , err := os . UserConfigDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to retrieve user config directory: %w" , err )
}
2026-02-02 12:58:28 +05:30
return filepath . Join ( userConfigDir , pmgDefaultHomeRelativePath ), nil
2026-01-01 12:33:52 +05:30
}
2026-05-21 14:32:30 +05:30
// userConfigFilePath computes the path to the per-user config file.
func userConfigFilePath () ( string , error ) {
2026-01-01 12:33:52 +05:30
configDir , err := configDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get config directory: %w" , err )
}
2026-02-02 12:58:28 +05:30
return filepath . Join ( configDir , pmgConfigFileName ), nil
2026-01-01 12:33:52 +05:30
}
2026-05-21 14:32:30 +05:30
// globalConfigDirOverride replaces the OS-level managed config directory. It
// exists only for tests within this package. There is intentionally no env var
// or flag for it, so a user cannot point the "managed" config at their own file
// and bypass the globally managed config.
var globalConfigDirOverride string
// globalConfigDir returns the OS-level directory for a globally managed config
// file, or "" when the platform has no such location.
func globalConfigDir () string {
if globalConfigDirOverride != "" {
return globalConfigDirOverride
}
switch runtime . GOOS {
case "darwin" :
return "/Library/Application Support/safedep/pmg"
case "linux" :
return "/etc/safedep/pmg"
case "windows" :
programData := os . Getenv ( "PROGRAMDATA" )
if programData == "" {
programData = `C:\ProgramData`
}
return filepath . Join ( programData , "safedep" , "pmg" )
}
return ""
}
// globalConfigFilePath returns the path to the globally managed config file, or
// "" when the platform has no global config location.
func globalConfigFilePath () string {
dir := globalConfigDir ()
if dir == "" {
return ""
}
return filepath . Join ( dir , pmgConfigFileName )
}
// resolveConfigFile picks the active config file. The globally managed file,
// when present, is authoritative and the per-user file is ignored entirely.
func resolveConfigFile () ( string , error ) {
if global := globalConfigFilePath (); global != "" && isRegularFile ( global ) {
return global , nil
}
return userConfigFilePath ()
}
func isRegularFile ( path string ) bool {
info , err := os . Stat ( path )
return err == nil && info . Mode (). IsRegular ()
}
2026-01-01 12:33:52 +05:30
// eventLogDir computes the path to the event log directory.
func eventLogDir () ( string , error ) {
// For rationale on why different directory for Windows, see:
// https://github.com/safedep/pmg/pull/82#discussion_r2636746036
switch runtime . GOOS {
case "windows" :
// Windows: %LOCALAPPDATA%\safedep\pmg\logs or %USERPROFILE%\safedep\pmg\logs
baseDir := os . Getenv ( "LOCALAPPDATA" )
if baseDir == "" {
baseDir = os . Getenv ( "USERPROFILE" )
if baseDir == "" {
return "" , fmt . Errorf ( "could not determine Windows user directory for event log storage" )
}
}
2026-02-02 12:58:28 +05:30
return filepath . Join ( baseDir , pmgDefaultHomeRelativePath , pmgDefaultLogDir ), nil
2026-01-01 12:33:52 +05:30
case "darwin" , "linux" :
configDir , err := configDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get config directory: %w" , err )
}
2026-02-02 12:58:28 +05:30
return filepath . Join ( configDir , pmgDefaultLogDir ), nil
2026-01-01 12:33:52 +05:30
default :
return "" , fmt . Errorf ( "unsupported operating system: %s" , runtime . GOOS )
}
}
2026-05-19 14:40:54 +05:30
// cacheDir computes the path to the cache root directory.
func cacheDir () ( string , error ) {
dir := os . Getenv ( pmgCacheDirEnvKey )
if dir != "" {
return dir , nil
}
switch runtime . GOOS {
case "windows" :
// Windows: %LOCALAPPDATA%\safedep\pmg or %USERPROFILE%\safedep\pmg
baseDir := os . Getenv ( "LOCALAPPDATA" )
if baseDir == "" {
baseDir = os . Getenv ( "USERPROFILE" )
if baseDir == "" {
return "" , fmt . Errorf ( "could not determine Windows user directory for cache storage" )
}
}
return filepath . Join ( baseDir , pmgDefaultHomeRelativePath ), nil
case "darwin" , "linux" :
2026-07-14 21:34:22 +05:30
if isSudoElevation () {
if base , err := rootCacheDirResolver (); err == nil {
return filepath . Join ( base , pmgDefaultHomeRelativePath ), nil
} else {
// Same fallback rationale as configDir.
log . Warnf ( "failed to resolve root home for cache dir, using environment: %v" , err )
}
}
2026-05-19 14:40:54 +05:30
userCacheDir , err := os . UserCacheDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to retrieve user cache directory: %w" , err )
}
return filepath . Join ( userCacheDir , pmgDefaultHomeRelativePath ), nil
default :
return "" , fmt . Errorf ( "unsupported operating system: %s" , runtime . GOOS )
}
}
// sandboxProfileDir computes the path to the sandbox profile directory.
func sandboxProfileDir () ( string , error ) {
configDir , err := configDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get config directory: %w" , err )
}
return filepath . Join ( configDir , pmgDefaultSandboxProfileDir ), nil
}
2026-05-26 21:06:57 +05:30
// sandboxOverlayDir computes the path to the per-repo sandbox overlay directory.
func sandboxOverlayDir () ( string , error ) {
configDir , err := configDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get config directory: %w" , err )
}
return filepath . Join ( configDir , pmgDefaultSandboxOverlayDir ), nil
}
2026-07-21 15:14:07 +05:30
// sandboxPresetDir computes the path to the user sandbox preset directory.
func sandboxPresetDir () ( string , error ) {
configDir , err := configDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get config directory: %w" , err )
}
return filepath . Join ( configDir , pmgDefaultSandboxPresetDir ), nil
}
2026-05-19 14:40:54 +05:30
// sandboxViolationCacheDir computes the path to the sandbox violation cache directory.
func sandboxViolationCacheDir () ( string , error ) {
cacheDir , err := cacheDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get cache directory: %w" , err )
}
return filepath . Join ( cacheDir , pmgDefaultSandboxViolationCacheDir ), nil
}
2026-06-22 10:12:02 +05:30
// localDBDir computes the directory holding PMG's shared localdb SQLite file
// and its WAL/shm siblings.
func localDBDir () ( string , error ) {
cacheDir , err := cacheDir ()
if err != nil {
return "" , fmt . Errorf ( "failed to get cache directory: %w" , err )
}
return filepath . Join ( cacheDir , pmgDefaultLocalDBDir ), nil
}
2026-01-01 12:33:52 +05:30
// Get returns the global configuration.
// This is the public API for the configuration package. This package should guarantee
// that this function will never return nil.
func Get () * RuntimeConfig {
return globalConfig
}
2026-07-10 17:42:26 +05:30
// AdvisoryMessage returns the org-configured advisory message appended to
// policy block output. Empty when not configured.
func AdvisoryMessage () string {
return globalConfig . Config . AdvisoryMessage
}
2026-04-28 17:49:58 +05:30
func ConfigureSandbox ( mayDownloadPackages bool ) {
2026-01-19 22:24:59 +05:30
if globalConfig . Config . Sandbox . Enabled {
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
2026-04-28 17:49:58 +05:30
// commands that may download packages (install, update, etc.)
globalConfig . Config . Sandbox . Enabled = globalConfig . Config . Sandbox . EnforceAlways || mayDownloadPackages
2026-01-19 22:24:59 +05:30
}
}
2026-03-31 19:21:01 +05:30
// WriteTemplateConfig writes the template configuration file to disk.
// If the config file does not exist, the full template is written.
// If it already exists, missing keys from the template are merged
// into the existing config while preserving all user values and comments.
2026-05-21 14:32:30 +05:30
//
// When a globally managed config is active, this is a no-op: the per-user
// file is ignored at load time, so creating it would only mislead.
2026-01-01 12:33:52 +05:30
func WriteTemplateConfig () error {
2026-05-21 14:32:30 +05:30
if globalConfig . IsManaged () {
return nil
}
configFilePath , err := userConfigFilePath ()
2026-01-01 12:33:52 +05:30
if err != nil {
return fmt . Errorf ( "failed to get config file path: %w" , err )
}
2026-07-14 21:34:22 +05:30
return writeTemplateConfigFile ( configFilePath )
}
// RemoveUserConfigFile deletes the per-user config file. It never touches the
// globally managed file. A missing file is not an error.
func RemoveUserConfigFile () error {
path , err := userConfigFilePath ()
if err != nil {
return fmt . Errorf ( "failed to get config file path: %w" , err )
}
return removeFileIfExists ( path )
}
// WriteSystemTemplateConfig writes the template configuration to the OS-level
// managed config path (e.g. /etc/safedep/pmg/config.yml on Linux). Used by
// `pmg setup install --system`.
func WriteSystemTemplateConfig () error {
path := globalConfigFilePath ()
if path == "" {
return fmt . Errorf ( "system config is not supported on %s" , runtime . GOOS )
}
// MkdirAll and WriteFile honor the process umask, so a hardened root
// umask (e.g. 077) would otherwise leave the managed config unreadable
// by non-root users — silently disabling the system-wide policy for
// them. Only directories created here and the file we write are touched;
// pre-existing directories keep their permissions.
if err := fsutil . MkdirAllRootOwned ( filepath . Dir ( path ), 0 o755 ); err != nil {
return err
}
if err := writeTemplateConfigFile ( path ); err != nil {
return err
}
return fsutil . ForceRootOwned ( path , 0 o644 )
}
// RemoveSystemConfigFile deletes the globally managed config file. A missing
// file is not an error. Returns an error when the platform has no system path.
func RemoveSystemConfigFile () error {
path := globalConfigFilePath ()
if path == "" {
return fmt . Errorf ( "system config is not supported on %s" , runtime . GOOS )
}
return removeFileIfExists ( path )
}
// SystemConfigDir returns the OS-level managed config directory, or "" when
// unsupported.
func SystemConfigDir () string {
return globalConfigDir ()
}
func writeTemplateConfigFile ( configFilePath string ) error {
if err := os . MkdirAll ( filepath . Dir ( configFilePath ), 0 o755 ); err != nil {
return fmt . Errorf ( "failed to create config directory: %w" , err )
}
2026-03-31 19:21:01 +05:30
existingConfig , err := os . ReadFile ( configFilePath )
if os . IsNotExist ( err ) {
return os . WriteFile ( configFilePath , [] byte ( templateConfig ), 0 o644 )
}
if err != nil {
return fmt . Errorf ( "failed to read existing config: %w" , err )
2026-01-01 12:33:52 +05:30
}
2026-03-31 19:21:01 +05:30
merged , err := utils . MergeYAML ( existingConfig , [] byte ( templateConfig ))
if err != nil {
return fmt . Errorf ( "failed to merge config: %w" , err )
}
if err := os . WriteFile ( configFilePath , merged , 0 o644 ); err != nil {
return fmt . Errorf ( "failed to write merged config: %w" , err )
2026-01-01 12:33:52 +05:30
}
return nil
2025-05-15 16:50:59 +05:30
}
2026-05-21 14:32:30 +05:30
2026-07-14 21:34:22 +05:30
func removeFileIfExists ( path string ) error {
2026-05-21 14:32:30 +05:30
if err := os . Remove ( path ); err != nil && ! os . IsNotExist ( err ) {
return fmt . Errorf ( "failed to remove config file %q: %w" , path , err )
}
return nil
}
// NewManagedConfigError returns the error shown when a user tries to change a
// 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.
func NewManagedConfigError () error {
2026-05-21 16:37:26 +05:30
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 {
2026-05-24 12:22:19 +05:30
return usefulerror . NewUsefulError ().
WithCode ( errcodes . PermissionDenied ).
2026-05-21 16:37:26 +05:30
WithHumanError ( message ).
2026-05-21 14:32:30 +05:30
WithHelp ( "This machine's PMG configuration is centrally managed. Contact your administrator to change it." ).
2026-05-21 16:37:26 +05:30
Wrap ( errors . New ( message ))
2026-05-21 14:32:30 +05:30
}