Files
pmg/config/config.go
T
dc0e3202cc feat: Linux system-wide setup (pmg setup install --system) (#377)
* feat: add Linux system-wide setup

Install shared shims, managed configuration, and login-shell PATH integration so golden images and multi-user hosts can protect package installs for every user.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: keep local design documents untracked

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden and simplify Linux system install

Tighten shim detection, profile repair, and install ordering while
trimming over-specific doctor/info hints from the system-install path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: clarify system-install doctor alias and shim path checks

Use UserBinDir for PATH checks and pass aliases as not required under
system install without treating that as active interception.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: tighten event-log soft-fail warning prefix

Prefix the warning with [pmg] and drop the redundant continuing clause.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: add Linux system-install e2e and pin pnpm for add flake

Cover root system setup, PATH/profile.d, managed config, non-root
interception, and remove. Pin pnpm 11.10.0 on the package-manager e2e
job after an integrity crash on pnpm add.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: bump packageManager to pnpm 11.10.0 for e2e

Align package.json with the pnpm version we want in CI so action-setup
stops erroring on a version mismatch after the e2e integrity flake.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: use npm init for pnpm e2e to avoid integrity crash

pnpm 11.x `pnpm init` still writes onFail:download; `pnpm add` then
fails after PMG analysis even on 11.10.0. Seed the temp package with
npm init instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: revert packageManager pin to pnpm 11.1.3

The e2e integrity crash is avoided by npm init; the 11.10.0 bump is
no longer needed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden system-install review findings

Require root-owned, non-group/other-writable pmg for --system install;
allow remove without that validation. Doctor checks npm resolution for
PATH precedence, uses ImpliesInterception instead of message matching,
and documents version-manager shadowing. Pass profile bin dir from the
shim manager and note that system config ignores per-user files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden doctor PATH checks and attribute cloud events by OS user

Doctor now verifies every installed package manager against the shim
directory, and system-install validation only requires a safe parent
directory. Cloud sync records username/uid on invocation context for
multi-user hosts sharing one endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address system-install review findings

- shim: make system executable resolution injectable so tests pass under
  umask 002; skip the root-owner test when running as root
- doctor: treat resolution into either the system or per-user shim dir as
  intercepted, and collapse the shim-in-PATH check to a single call site
- setup: make remove (both --system and per-user) best-effort with
  errors.Join so one failed step no longer strands the other artifact
- shim: allow a group-writable install parent dir (Debian/Ubuntu ship
  /usr/local/bin as root:staff 2775) while still rejecting world-writable
  and non-root-owned parents
- audit: attribute cloud events to SUDO_USER when running under sudo
- docs: drop the soft-fail event-logging claim (hard-fail is retained)

* ci: normalize /usr/local/bin perms before system-install e2e

The GitHub ubuntu-latest runner ships /usr/local/bin world-writable so
tooling can install without sudo. System install correctly refuses a
world-writable dir for the shared binary (any local user could replace
it and hijack every user's npm/pip). No FHS-compliant distro or Docker
image ships it world-writable — it is always root:root 0755 or
root:staff 2775 — so this normalizes only the anomalous CI runner back
to standard perms and still exercises the real /usr/local/bin path.

* fix: actionable remedy for root-created per-user config dir

A pmg run as root with a preserved HOME (GitHub runners, sudo -E, su
without -) creates the invoking user's ~/.config/safedep as root-owned,
and event-log init then fail-closes every later non-root command.
Make that state self-solvable:

- event-log init permission errors exit with a usefulerror naming the
  likely cause and the chown fix instead of a bare fatal
- pmg setup doctor probes event-log dir writability and reports the
  same fix via a new per-result Fix override
- document the mechanism and remedy in system-install.md, along with
  the binary ownership requirements for --system
- consolidate this branch's doctor tests into doctor_test.go

* fix: resolve per-user paths from root's own home when running as root

Path resolution trusted HOME (and XDG_*), which sudo and su can preserve
from the invoking user (GitHub runners, sudo -E, su without -). Any pmg
run as root then created root-owned ~/.config/safedep inside that user's
home, and event-log init fail-closed every later non-root pmg/npm/pip
run for them. System install made sudo pmg the documented flow, turning
this latent bug into the happy path.

When euid is 0, configDir and cacheDir now resolve from root's passwd
home instead of the environment, so root state lands under /root and
user homes are never touched. PMG_CONFIG_DIR/PMG_CACHE_DIR still win,
non-root resolution is unchanged, and Windows is unaffected (no euid).
Event-log init stays fatal on failure; sudo-run package events are
attributed via SUDO_USER and synced by the exit auto-sync as usual.

E2E: GitHub runners preserve HOME under sudo, so assert that no sudo
pmg run leaks state into the runner's home, and that the managed-config
refusal fails for the documented reason rather than a permission brick.

* fix: triage the unwritable config dir remedy by cause

The chown hint is only correct when another account created files
inside the current user's own home. When a leaked HOME or
XDG_CONFIG_HOME points at another user's home (e.g. sudo -u on GitHub
runners), following it would chown that user's directory and brick
their pmg instead. Classify the failure against the passwd home,
which the leaked environment cannot influence, and prescribe:

- dir inside own home: restore ownership with chown
- dir outside own home: fix the leaked environment, never chown
- explicit PMG_CONFIG_DIR: make it writable

Used by both the fatal event-log error and the doctor check, and the
docs troubleshooting now carries the same two-case triage.

* ci: pin XDG_CONFIG_HOME for the cross-user e2e step; terse doctor fix

GitHub runners export XDG_CONFIG_HOME=/home/runner/.config and it leaks
through sudo -u, so the pmgtest pmg resolved the runner user's config
dir and fail-closed on its runner-owned log file (run 29289868727 shows
the triaged error catching exactly this). Set it inside the login shell
so it wins regardless of how the leak is delivered.

The remedy now returns a full-help and doctor-table pair from a single
triage, and drops the do-not-chown tail from the leak message.

* fix: adapt event-log error to the two-value remedy signature

Belongs with the previous commit; it was left unstaged and e0580b1
does not compile without it.

* fix: fall back to env path resolution when root has no passwd entry

Running as uid 0 without a resolvable root passwd entry (scratch
containers, minimal chroots) panicked at startup on every command,
because the euid-based path resolution had no fallback. Fall back to
env-derived resolution there: without a passwd database there is no
user switching, so the cross-user poisoning that branch prevents
cannot occur.

Also restore the underlying cause in the generic event-log init error
(minimal output hid it after the usefulerror change), and document
that root's per-user data lives under /root regardless of a preserved
HOME.

* fix: gate SUDO_USER trust and root path diversion; add doctor binary check

Address review findings on the system-install PR:

- cloud_sink: honor SUDO_USER for audit attribution only when euid==0.
  Without the gate any user could set SUDO_USER and spoof cloud-audit
  attribution to another account. Matches the guard in cmd/setup/cert.go.

- config: divert per-user paths to root's passwd home only on an actual
  sudo elevation (euid==0 && SUDO_USER set), not for every root euid.
  The blanket root diversion ignored HOME/XDG_CONFIG_HOME and silently
  stopped reading genuine root users' config (golden Docker images),
  regressing two tests that only fail when the suite runs as root.
  Genuine root honors the environment as before; su without - leaves no
  marker and stays a documented, loud-failing residual.

- doctor: add a system-only check re-validating that the binary the
  installed shims exec is still root-owned and non-writable, catching
  permission/ownership drift after install.

- shim: fold the duplicated shim-scan loop into firstShimContent.

* fix: harden system dirs at install; keep sudo attribution without passwd

Address remaining review comments:

- shim: force root:root 0755 on the managed system dirs (shim tree and
  profile.d) after MkdirAll. A pre-created dir with weaker ownership,
  possible under Debian's group-writable /usr/local/lib, would let a
  non-root user replace the shims every account executes.

- audit: when SUDO_USER has no passwd entry (minimal containers),
  attribute cloud events from sudo's recorded SUDO_USER/SUDO_UID env
  instead of falling back to root. Still gated on euid 0.

- setup: reword the root-without---system warning; alias/shim install
  follows HOME, so claiming it configures only root's home was wrong.

- shim: skip the non-root-owner validation test on Windows, where file
  ownership is not resolvable.

* fix: reject system binaries unreachable by other users; consistent info

The system-install validation checked the binary's own permissions and
the parent's tamper-safety but never reachability: a 0755 root-owned
binary under a 0700 directory (e.g. /root/pmg) passed every check while
every non-root user's shim failed with exit 127. Walk the directory
chain to / and require the search bit for others; doctor's system
binary check inherits this. E2E gains a reject case for a binary under
a non-searchable directory.

setup info: render alias/user-shim/system-shim rows through one
installed-state formatter (location when installed, "not installed"
otherwise) instead of a mix of booleans, paths, and prose.

* fix: stop reowning /etc/profile.d; document group-writable and su gaps

writeSystemProfile chowned/chmod'd /etc/profile.d itself, a shared
system directory pmg does not own, silently overriding any perms a
sysadmin set on it. Secure only the file pmg writes (pmg.sh) via
secureSystemFile, which also forces 0644 explicitly so a restrictive
root umask cannot leave the snippet non-world-readable (which would
drop the shim dir from other users' login-shell PATH).

Docs: add Limitations entries for the group-writable install dir
bypass (validation is defeatable on non-sticky group-writable dirs
like Debian's /usr/local/bin) and the elevation-only scope (su without
- can still poison the caller's home; sudo -u cannot poison another
account). Trim the requireSafeParentDir comment to a pointer.

* refactor: separate unwritable-dir diagnosis from remedy rendering

Address the open review threads on #376:
- rename realUserHomeDir to currentUserHomeDir and fail when the passwd
  entry has no home directory
- split UnwritableConfigDirRemedy into classifyUnwritableDir (cause
  diagnosis) and pure message rendering so each function has one job
- replace cmd/setup's duplicate pathIsUnderDir with the shared
  config.PathWithinDir, now guarding empty inputs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A

* fix: repair umask-clamped modes on system shims and managed config

os.WriteFile and os.MkdirAll honor the process umask, so a hardened root
umask (e.g. 077) produced 0700 shims other users cannot execute and a
0600/0700 managed config non-root pmg runs cannot read - silently
disabling the system-wide policy. Chmod/chown the artifacts explicitly
after writing, with unix regression tests running under umask 077.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A

* refactor: address system-install maintainability review

- move PathWithinDir and the root-owned mkdir/chmod helpers into a new
  internal/fsutil package; config no longer exports a generic fs util
- create managed-config directories with MkdirAllRootOwned, which only
  sets ownership and mode on directories it creates - drops the
  hardcoded safedep parent-dir heuristic and never touches pre-existing
  directories
- collapse NewSystemShimManagerForRemove into NewSystemShimManager and
  validate the executable in Install, where the action needs it; Remove
  works regardless of binary state
- rename ShimConfig.ManageProfile to SystemProfile and document it
- name the Linux-only system paths linuxSystemBinDir/ProfilePath and
  document the Unix-only validation semantics
- share the PMG_BIN shim variable name between writeShimScript and
  parseShimPMGBin via the shimPMGBinVar constant
- cloud attribution falls back to the effective uid when SUDO_UID is
  absent, so sudo-invoked commands are not misattributed to root

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rWmR6NS47FpedJsrTWk8A

---------

Co-authored-by: Sahilb315 <bansalsahil315@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 21:34:22 +05:30

1089 lines
38 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
"time"
_ "embed"
"github.com/safedep/pmg/internal/fsutil"
"github.com/safedep/dry/log"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/errcodes"
"github.com/spf13/viper"
)
const (
// Verbosity level constants for the config file
VerbositySilent = "silent"
VerbosityNormal = "normal"
VerbosityVerbose = "verbose"
// Environment variable key for the insecure installation flag
pmgInsecureInstallationEnvKey = "PMG_INSECURE_INSTALLATION"
// Allow overriding the config path from the environment
pmgConfigDirEnvKey = "PMG_CONFIG_DIR"
// Allow overriding the cache path from the environment
pmgCacheDirEnvKey = "PMG_CACHE_DIR"
// Config path is computed as the user config directory + the default relative path
// when not overridden by the environment variable
pmgDefaultHomeRelativePath = "safedep/pmg"
// Default log directory is relative to the config directory.
pmgDefaultLogDir = "logs"
// Default sandbox profile directory is relative to the config directory.
pmgDefaultSandboxProfileDir = "sandbox/profiles"
// Default sandbox overlay directory is relative to the config directory.
// Per-repo overlays persisted by `pmg sandbox allow` live here.
pmgDefaultSandboxOverlayDir = "sandbox/overlays"
// Default sandbox violation cache directory is relative to the cache root.
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
// Default localdb directory is relative to the cache root.
pmgDefaultLocalDBDir = "localdb"
// Default localdb file name for PMG's shared SQLite database.
pmgDefaultLocalDBFileName = "pmg.db"
// 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.
pmgConfigFileName = "config.yml"
)
//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 {
Transitive bool `mapstructure:"transitive"`
TransitiveDepth int `mapstructure:"transitive_depth"`
IncludeDevDependencies bool `mapstructure:"include_dev_dependencies"`
// Paranoid enables high-security defaults (e.g., treating suspicious behavior as malicious).
Paranoid bool `mapstructure:"paranoid"`
// DisableTelemetry allows turning off telemetry collection.
DisableTelemetry bool `mapstructure:"disable_telemetry"`
// TrustedPackages allows for trusting a suspicious package and ignoring the suspicious behaviour for the package in future installations
TrustedPackages []TrustedPackage `mapstructure:"trusted_packages"`
// AdvisoryMessage is an optional org-specific message appended to every
// block output, regardless of which control blocked the installation.
AdvisoryMessage string `mapstructure:"advisory_message"`
// 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"`
// Deprecated: Use Proxy.Enabled instead. Kept for backward compatibility with old config files.
ProxyMode bool `mapstructure:"proxy_mode"`
// 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".
Verbosity string `mapstructure:"verbosity"`
// 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"`
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
// 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"`
Cloud CloudConfig `mapstructure:"cloud"`
Proxy ProxyConfig `mapstructure:"proxy"`
}
// 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"`
}
// CloudConfig configures audit event sync to SafeDep Cloud.
type CloudConfig struct {
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"`
}
type ProxyConfig struct {
Enabled bool `mapstructure:"enabled"`
InstallOnly bool `mapstructure:"install_only"`
SkipCommands map[string][]string `mapstructure:"skip_commands"`
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"`
}
// 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"`
// 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"`
// 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"`
}
// 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"`
// Skip is a per-control skip list of packages exempt from the cooldown
// 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
// 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"`
}
// 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",
"uvx": "uvx",
},
}
// 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
}
// 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"`
}
// 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"`
purlRef
}
// 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.
DryRun bool
// InsecureInstallation allows bypassing install blocking on malicious packages
InsecureInstallation bool
// 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
// 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
// Internal config values computed at runtime and must be accessed via. API
configDir string
configFilePath string // active config: globally managed file if present, else per-user
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
sandboxProfileDir string
sandboxOverlayDir string
sandboxViolationCacheDir string
localDBDir string
cacheDir string
viper *viper.Viper
}
// CloudSyncDBPath returns the path to the cloud sync WAL database.
func (r *RuntimeConfig) CloudSyncDBPath() string {
return filepath.Join(r.configDir, "cloud-sync.db")
}
// 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")
}
// ConfigFilePath returns the path to the active config file (the globally
// managed file when present, otherwise the per-user file).
func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath
}
// 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
}
// 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.
func (r *RuntimeConfig) EventLogDir() string {
return r.eventLogDir
}
// ConfigDir returns the path to the config directory.
func (r *RuntimeConfig) ConfigDir() string {
return r.configDir
}
// SandboxProfileDir returns the path to the user sandbox profile directory.
func (r *RuntimeConfig) SandboxProfileDir() string {
return r.sandboxProfileDir
}
// SandboxOverlayDir returns the path to the per-repo sandbox overlay directory.
func (r *RuntimeConfig) SandboxOverlayDir() string {
return r.sandboxOverlayDir
}
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
func (r *RuntimeConfig) SandboxViolationCacheDir() string {
return r.sandboxViolationCacheDir
}
// 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
}
// 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
}
func (r *RuntimeConfig) IsProxyModeEnabled() bool {
return r.Config.Proxy.Enabled
}
// 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"
SandboxAllowEnv SandboxAllowType = "env"
)
// SandboxAllowOverride represents a single --sandbox-allow flag value.
type SandboxAllowOverride struct {
// Type is the resource type (read, write, exec, net-connect, net-bind).
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
}
// 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.
insecureInstallation := utils.EnvBool(pmgInsecureInstallationEnvKey, false)
return RuntimeConfig{
Config: Config{
Transitive: true,
TransitiveDepth: 5,
IncludeDevDependencies: false,
Paranoid: false,
DisableTelemetry: false,
EventLogRetentionDays: 7,
SkipEventLogging: false,
TrustedPackages: []TrustedPackage{},
AdvisoryMessage: "",
ProxyMode: true,
Verbosity: VerbosityNormal,
Sandbox: SandboxConfig{
Enabled: false,
EnforceAlways: false,
},
DependencyCooldown: DependencyCooldownConfig{
Enabled: true,
Days: 5,
},
AnalysisCache: AnalysisCacheConfig{
Malysis: MalysisCacheConfig{
Enabled: false,
TTL: 24 * time.Hour,
},
},
Cloud: CloudConfig{
Enabled: false,
AutoSync: CloudAutoSyncConfig{
Enabled: true,
MinInterval: 15 * time.Minute,
Timeout: 5 * time.Minute,
},
},
Proxy: ProxyConfig{
Enabled: true,
InstallOnly: false,
SkipCommands: map[string][]string{},
Server: ProxyServerConfig{
ListenHost: "127.0.0.1",
},
},
},
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()
}
// 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()
}
// 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))
}
activeConfigPath, err := resolveConfigFile()
if err != nil {
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))
}
eventLogDir, err := eventLogDir()
if err != nil {
panic(fmt.Errorf("failed to get event log directory: %w", err))
}
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))
}
sandboxOverlayDir, err := sandboxOverlayDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox overlay directory: %w", err))
}
cacheRootDir, err := cacheDir()
if err != nil {
panic(fmt.Errorf("failed to get cache directory: %w", err))
}
localDBDir, err := localDBDir()
if err != nil {
panic(fmt.Errorf("failed to get localdb directory: %w", err))
}
globalConfig.configDir = configDir
globalConfig.configFilePath = activeConfigPath
globalConfig.userConfigFilePath = userConfigPath
globalConfig.eventLogDir = eventLogDir
globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxOverlayDir = sandboxOverlayDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
globalConfig.localDBDir = localDBDir
globalConfig.cacheDir = cacheRootDir
// 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()
if err := preprocessPackageRefs(&globalConfig.Config); err != nil {
log.Warnf("Failed to preprocess package refs: %v", err)
}
}
// loadConfig loads the configuration from the config file.
// This is where we determine the source of config and use the appropriate loader.
// 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.
func loadConfig() {
if err := loadViperConfig(); err != nil {
log.Warnf("Failed to load config, using defaults: %v", err)
}
}
// 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") != ""
}
// configDir computes the path to the config directory.
func configDir() (string, error) {
dir := os.Getenv(pmgConfigDirEnvKey)
if dir != "" {
return dir, nil
}
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)
}
}
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to retrieve user config directory: %w", err)
}
return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil
}
// userConfigFilePath computes the path to the per-user config file.
func userConfigFilePath() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgConfigFileName), nil
}
// 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()
}
// 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")
}
}
return filepath.Join(baseDir, pmgDefaultHomeRelativePath, pmgDefaultLogDir), nil
case "darwin", "linux":
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultLogDir), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// 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":
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)
}
}
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
}
// 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
}
// 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
}
// 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
}
// 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
}
// AdvisoryMessage returns the org-configured advisory message appended to
// policy block output. Empty when not configured.
func AdvisoryMessage() string {
return globalConfig.Config.AdvisoryMessage
}
func ConfigureSandbox(mayDownloadPackages bool) {
if globalConfig.Config.Sandbox.Enabled {
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
// commands that may download packages (install, update, etc.)
globalConfig.Config.Sandbox.Enabled = globalConfig.Config.Sandbox.EnforceAlways || mayDownloadPackages
}
}
// 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.
//
// 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.
func WriteTemplateConfig() error {
if globalConfig.IsManaged() {
return nil
}
configFilePath, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
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), 0o755); err != nil {
return err
}
if err := writeTemplateConfigFile(path); err != nil {
return err
}
return fsutil.ForceRootOwned(path, 0o644)
}
// 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), 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
existingConfig, err := os.ReadFile(configFilePath)
if os.IsNotExist(err) {
return os.WriteFile(configFilePath, []byte(templateConfig), 0o644)
}
if err != nil {
return fmt.Errorf("failed to read existing config: %w", err)
}
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, 0o644); err != nil {
return fmt.Errorf("failed to write merged config: %w", err)
}
return nil
}
func removeFileIfExists(path string) error {
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 {
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.NewUsefulError().
WithCode(errcodes.PermissionDenied).
WithHumanError(message).
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
Wrap(errors.New(message))
}