From 8f6d0fcce0151dd22e362426bd369484f3db15c7 Mon Sep 17 00:00:00 2001 From: Sahilb315 Date: Fri, 10 Jul 2026 23:33:52 +0530 Subject: [PATCH] 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 --- .../system_install_design_98a8f15f.plan.md | 164 +++++++++++++++++ README.md | 2 + cmd/setup/doctor.go | 48 ++++- cmd/setup/info.go | 7 + cmd/setup/setup.go | 169 ++++++++++++++---- cmd/setup/setup_test.go | 66 +++++++ config/config.go | 66 +++++-- config/managed_config_test.go | 16 ++ docs/config.md | 4 + docs/development.md | 1 + docs/persistent-proxy.md | 4 +- docs/specs/2026-07-10-system-install-spec.md | 68 +++++++ docs/system-install.md | 124 +++++++++++++ go.work.sum | 1 + internal/shim/shim.go | 35 ++++ internal/shim/system.go | 119 ++++++++++++ internal/shim/system_test.go | 73 ++++++++ internal/ui/info.go | 10 ++ main.go | 4 +- 19 files changed, 915 insertions(+), 66 deletions(-) create mode 100644 .cursor/plans/system_install_design_98a8f15f.plan.md create mode 100644 cmd/setup/setup_test.go create mode 100644 docs/specs/2026-07-10-system-install-spec.md create mode 100644 docs/system-install.md create mode 100644 internal/shim/system.go create mode 100644 internal/shim/system_test.go diff --git a/.cursor/plans/system_install_design_98a8f15f.plan.md b/.cursor/plans/system_install_design_98a8f15f.plan.md new file mode 100644 index 0000000..0973fb0 --- /dev/null +++ b/.cursor/plans/system_install_design_98a8f15f.plan.md @@ -0,0 +1,164 @@ +--- +name: System install design +overview: "Design for `pmg setup install --system` (Linux): general system-wide shims + config + `/etc/profile.d`, motivated by Docker golden images but not Docker-specific. Venv-after-activate remains a documented limitation." +todos: + - id: spec + content: Write design spec to docs/specs/ after user approves design + status: completed + - id: eventlog-root + content: Eventlog soft-fail + root-without--system warning + status: completed + - id: system-artifacts + content: System shim manager, profile.d writer, WriteSystemTemplateConfig + status: completed + - id: cli-flags + content: Wire --system on install/remove with uid checks + status: completed + - id: info-doctor + content: Update setup info and doctor for system scope + status: completed + - id: docs-tests + content: Docs (golden image, venv limit) + unit tests + status: completed +isProject: false +--- + +# PMG System Install (`--system`) — Design + +Re-evaluation of [issue #317](https://github.com/safedep/pmg/issues/317) against current codebase. Prerequisite [PR #323](https://github.com/safedep/pmg/pull/323) (`PMG_SHIM_PATH`) is already merged. + +## Goal + +Root can install PMG once for **all users** on a Linux host (golden Docker image, shared VM). Later `USER appuser` / non-root logins get interception without per-user `pmg setup install`. + +Primary motivator: package installs inside `docker build` (host GHA PMG cannot see them). Feature itself is a **general Linux system install**, not a Docker mode. + +## Non-goals (v1) + +- macOS / Windows `--system` +- Persistent proxy baked into images +- In-place wrap of `/usr/local/bin` binaries +- Shell **function** wrappers (venv interactive fix) +- Making host PMG intercept `docker build` from outside the image +- Closing the venv-after-`activate` PATH race via shims + +## Locked decisions + + +| Topic | Choice | +| -------------------------- | -------------------------------------------------------------------------------------------- | +| Scope | Linux only | +| Shim location | `/usr/local/lib/pmg/bin` (not `/usr/local/bin`) | +| Config | Write `/etc/safedep/pmg/config.yml` (read path already exists) | +| PATH on VMs / login shells | `/etc/profile.d/pmg.sh` prepends shim dir | +| PATH in Docker builds | Image author sets `ENV PATH="/usr/local/lib/pmg/bin:$PATH"` (documented; required for `RUN`) | +| Venv after `activate` | Documented limitation → use `pmg pip …` | +| Proxy-in-image | Rejected for this use case | +| Layers | System and per-user installs independent | + + +## User flows + +### Golden Docker base + +```dockerfile +RUN curl -fsSL … | sh && pmg setup install --system +ENV PATH="/usr/local/lib/pmg/bin:$PATH" +``` + +Child images: + +```dockerfile +FROM company/golden:latest +USER appuser +RUN npm ci # intercepted +RUN pip install pkg # intercepted (system pip) +# RUN .venv/bin/activate && pip install x # NOT intercepted — use pmg pip +``` + +### Multi-user Linux VM + +```bash +sudo pmg setup install --system +# new login shells get PATH via /etc/profile.d/pmg.sh +``` + +### CLI UX + +```bash +sudo pmg setup install --system # root-only; fail fast otherwise +sudo pmg setup remove --system +pmg setup info # reports system / user / both +pmg setup doctor # checks system shim dir + on PATH +``` + +- Root without `--system` → warn (other users not covered; point to `--system`), then proceed with per-user install into root’s HOME. +- Non-root `--system` → `usefulerror` + sudo guidance. + +## Architecture + +```mermaid +flowchart TB + subgraph install [pmg setup install --system] + Cfg["/etc/safedep/pmg/config.yml"] + Shims["/usr/local/lib/pmg/bin/*"] + Profile["/etc/profile.d/pmg.sh"] + end + subgraph path [How PATH gets shims] + Env["Docker ENV PATH"] + Prof["profile.d on login shells"] + end + Shims --> Env + Shims --> Prof + Env --> Run["Dockerfile RUN / any process with ENV"] + Prof --> Login["VM interactive/login shells"] +``` + + + +### Components to add/change + +- `[cmd/setup/setup.go](cmd/setup/setup.go)` — `--system` flag, uid-0 preflight, branch install/remove; root warning without flag +- `[config/config.go](config/config.go)` — `WriteSystemTemplateConfig()` → `globalConfigDir()`; remove only with `--system` +- `[internal/shim/shim.go](internal/shim/shim.go)` — `NewSystemShimManager()` with `BinDir=/usr/local/lib/pmg/bin`, no per-user rc edits +- New system profile writer — `/etc/profile.d/pmg.sh` (marker-gated, PATH prepend only) +- `[cmd/setup/info.go](cmd/setup/info.go)` / `[cmd/setup/doctor.go](cmd/setup/doctor.go)` — system scope reporting + PATH check +- `[main.go](main.go)` / eventlog — soft-fail when log init fails (unusable HOME); do not `Fatalf` + +Reuse existing `PMG_SHIM_PATH` filtering in `[internal/shim/path.go](internal/shim/path.go)`; no recursion work needed for the new shim dir. + +## Corner cases (handled or documented) + + +| Case | Handling | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Docker `RUN` does not source profile.d | Document `ENV PATH=…` in golden image | +| Login shell resets PATH via `/etc/profile` | profile.d restores shim dir | +| Child Dockerfile overwrites `PATH` without shim dir | Document; doctor can detect when run | +| Venv `activate` then `pip` | Documented limitation → `pmg pip` | +| System + per-user both present | Independent; remove is scoped | +| `PMG_SHIM_PATH` unset + direct `pmg npm` | Legacy `/.pmg/bin` only; system dir not stripped — prefer shim invocation or ensure env from shim | +| Unwritable HOME | Eventlog soft-fail | +| Windows | `--system` → not supported | + + +## Testing + +- Unit tests with path overrides (same pattern as `globalConfigDirOverride`) +- Install/remove idempotence for system shims + profile.d +- Doctor/info system detection +- Document a minimal Dockerfile golden-image smoke example in docs (not necessarily CI matrix in v1) + +## Docs + +- Setup / config docs: `--system`, golden-image snippet, VM vs Docker PATH, venv limitation +- Clarify host GHA PMG does not protect in-image `docker build` installs — bake PMG into the image + +## Implementation order (after spec approval) + +1. Soft-fail eventlog + root warn (small, unblocks containers) +2. System shim manager + profile.d writer + config write +3. Wire `--system` on install/remove +4. info/doctor +5. Docs + tests + diff --git a/README.md b/README.md index e54ec0b..1868c75 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ pmg setup install ``` > **Tip:** Re-run `pmg setup install` after upgrading PMG to pick up new configuration options. +> +> Linux all-users / golden images: `sudo pmg setup install --system` — see [docs/system-install.md](./docs/system-install.md). Validate your installation and verify protection is working: diff --git a/cmd/setup/doctor.go b/cmd/setup/doctor.go index e115440..ab55846 100644 --- a/cmd/setup/doctor.go +++ b/cmd/setup/doctor.go @@ -91,11 +91,17 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkEventLogDir, Category: "Configuration", Run: func() doctor.CheckResult { + if cfg.Config.SkipEventLogging { + return doctor.CheckResult{ + Status: doctor.StatusWarn, + Message: "Event logging is disabled", + } + } info, err := os.Stat(cfg.EventLogDir()) if err != nil { return doctor.CheckResult{ - Status: doctor.StatusFail, - Message: "Event log directory not found", + Status: doctor.StatusWarn, + Message: "Event log directory not found (PMG still runs without event logs)", } } if !info.IsDir() { @@ -114,6 +120,12 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkShellAliases, Category: "Shell Integration", Run: func() doctor.CheckResult { + if shim.SystemShimsInstalled() { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: "System shims installed (aliases optional)", + } + } aliasCfg := alias.DefaultConfig() rcFileManager, err := alias.NewDefaultRcFileManager(aliasCfg.RcFileName) if err != nil { @@ -146,6 +158,12 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkShimDirectory, Category: "Shell Integration", Run: func() doctor.CheckResult { + if shim.SystemShimsInstalled() { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: fmt.Sprintf("System shim directory found (%s)", shim.SystemBinDir()), + } + } sm, err := shim.NewDefaultShimManager() if err != nil { return doctor.CheckResult{ @@ -171,6 +189,20 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { Name: checkShimInPath, Category: "Shell Integration", Run: func() doctor.CheckResult { + pathEntries := filepath.SplitList(os.Getenv("PATH")) + if shim.SystemShimsInstalled() { + systemDir := shim.SystemBinDir() + if slices.Contains(pathEntries, systemDir) { + return doctor.CheckResult{ + Status: doctor.StatusPass, + Message: "System shim directory is in PATH", + } + } + return doctor.CheckResult{ + Status: doctor.StatusFail, + Message: fmt.Sprintf("System shim directory not in PATH (add ENV PATH=\"%s:$PATH\" for Docker RUN)", systemDir), + } + } sm, err := shim.NewDefaultShimManager() if err != nil { return doctor.CheckResult{ @@ -179,7 +211,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult { } } shimDir := sm.GetBinDir() - if slices.Contains(filepath.SplitList(os.Getenv("PATH")), shimDir) { + if slices.Contains(pathEntries, shimDir) { return doctor.CheckResult{ Status: doctor.StatusPass, Message: "Shim directory is in PATH", @@ -334,15 +366,15 @@ var checkDisplayNames = map[string]string{ var checkFixes = map[string]string{ checkConfigFile: "pmg setup install", checkEventLogDir: "pmg setup install", - checkShellAliases: "pmg setup install", - checkShimDirectory: "pmg setup install", - checkShimInPath: "Restart shell or source config", + checkShellAliases: "pmg setup install [--system]", + checkShimDirectory: "pmg setup install [--system]", + checkShimInPath: "Restart shell, source profile, or set ENV PATH for Docker", checkProxyMode: "Set proxy.enabled: true in config", checkSandbox: "Set sandbox.enabled: true in config", checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config", checkEventLogging: "Set skip_event_logging: false in config", - checkProtectionNpm: "pmg setup install", - checkProtectionPip: "pmg setup install", + checkProtectionNpm: "pmg setup install [--system]", + checkProtectionPip: "pmg setup install [--system]", checkCA: "pmg setup cert install", } diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 74fe913..b2390dc 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -13,6 +13,7 @@ import ( "github.com/safedep/pmg/internal/alias" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/audit" + "github.com/safedep/pmg/internal/shim" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/version" "github.com/safedep/pmg/proxy/certmanager" @@ -78,6 +79,12 @@ func executeSetupInfo() error { shellEntries["Detected Shell"] = shell shellEntries["Alias Installed"] = strconv.FormatBool(isInstalled) + shellEntries["User Shims"] = strconv.FormatBool(shim.UserShimsInstalled()) + shellEntries["System Shims"] = strconv.FormatBool(shim.SystemShimsInstalled()) + shellEntries["System Profile"] = strconv.FormatBool(shim.SystemProfileInstalled()) + if shim.SystemShimsInstalled() { + shellEntries["System Shim Dir"] = shim.SystemBinDir() + } ui.PrintInfoSection("Shell Integration", shellEntries) // Security section diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 4e00d18..858c8d7 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -1,10 +1,14 @@ package setup import ( + "errors" "fmt" + "os" "runtime" + "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/config" + "github.com/safedep/pmg/errcodes" "github.com/safedep/pmg/internal/alias" "github.com/safedep/pmg/internal/shim" "github.com/safedep/pmg/internal/ui" @@ -12,7 +16,13 @@ import ( "github.com/spf13/cobra" ) -var setupRemoveConfigFile = false +var ( + setupRemoveConfigFile bool + setupInstallSystem bool + setupRemoveSystem bool +) + +var setupGeteuid = os.Geteuid func NewSetupCommand() *cobra.Command { setupCmd := &cobra.Command{ @@ -35,7 +45,7 @@ func NewSetupCommand() *cobra.Command { } func NewInstallCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "install", Short: "Setup PMG config, aliases, and shims for package managers (npm, pnpm, pip, and more)", SilenceUsage: true, @@ -44,9 +54,20 @@ func NewInstallCommand() *cobra.Command { return install() }, } + cmd.Flags().BoolVar(&setupInstallSystem, "system", false, "Install system-wide for all users (Linux, requires root)") + return cmd } func install() error { + if setupInstallSystem { + return installSystem() + } + + if setupGeteuid() == 0 { + fmt.Printf("%s %s\n", ui.Colors.Yellow("⚠"), + "Running as root without --system configures only root's home. Use `pmg setup install --system` so all users are covered.") + } + if err := config.WriteTemplateConfig(); err != nil { return fmt.Errorf("failed to write template config: %w", err) } @@ -88,6 +109,28 @@ func install() error { return nil } +func installSystem() error { + if err := errIfSystemInstallAllowed(); err != nil { + return err + } + + if err := config.WriteSystemTemplateConfig(); err != nil { + return fmt.Errorf("failed to write system config: %w", err) + } + + shimMgr, err := shim.NewSystemShimManager() + if err != nil { + return fmt.Errorf("failed to create system shim manager: %w", err) + } + + if err := shimMgr.Install(); err != nil { + return fmt.Errorf("failed to install system shims: %w", err) + } + + ui.PrintSetupSystemInstallCmdInfo(shimMgr.GetBinDir(), config.SystemConfigDir(), shim.SystemProfilePath()) + return nil +} + func NewRemoveCommand() *cobra.Command { cmd := &cobra.Command{ Use: "remove", @@ -95,45 +138,95 @@ func NewRemoveCommand() *cobra.Command { SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit)) - - if setupRemoveConfigFile { - // Only ever remove the per-user file; the globally managed - // config is not ours to delete from a per-user uninstall. - if err := config.RemoveUserConfigFile(); err != nil { - return err - } - } - - if runtime.GOOS == "windows" { - fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.") - return nil - } - - cfg := alias.DefaultConfig() - rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName) - if err != nil { - return err - } - - aliasManager := alias.New(cfg, rcFileManager) - if err := aliasManager.Remove(); err != nil { - return fmt.Errorf("failed to remove aliases: %w", err) - } - - shimMgr, err := shim.NewDefaultShimManager() - if err != nil { - return fmt.Errorf("failed to create shim manager: %w", err) - } - - if err := shimMgr.Remove(); err != nil { - return fmt.Errorf("failed to remove shims: %w", err) - } - - fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect") - return nil + return remove() }, } cmd.Flags().BoolVar(&setupRemoveConfigFile, "config-file", false, "Remove the config file") + cmd.Flags().BoolVar(&setupRemoveSystem, "system", false, "Remove system-wide install (Linux, requires root)") return cmd } + +func remove() error { + if setupRemoveSystem { + return removeSystem() + } + + if setupRemoveConfigFile { + // Only ever remove the per-user file; the globally managed + // config is not ours to delete from a per-user uninstall. + if err := config.RemoveUserConfigFile(); err != nil { + return err + } + } + + if runtime.GOOS == "windows" { + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config removed. No aliases or shims to clean up on Windows.") + return nil + } + + cfg := alias.DefaultConfig() + rcFileManager, err := alias.NewDefaultRcFileManager(cfg.RcFileName) + if err != nil { + return err + } + + aliasManager := alias.New(cfg, rcFileManager) + if err := aliasManager.Remove(); err != nil { + return fmt.Errorf("failed to remove aliases: %w", err) + } + + shimMgr, err := shim.NewDefaultShimManager() + if err != nil { + return fmt.Errorf("failed to create shim manager: %w", err) + } + + if err := shimMgr.Remove(); err != nil { + return fmt.Errorf("failed to remove shims: %w", err) + } + + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG aliases and shims removed. Restart your terminal for changes to take effect") + return nil +} + +func removeSystem() error { + if err := errIfSystemInstallAllowed(); err != nil { + return err + } + + if setupRemoveConfigFile { + if err := config.RemoveSystemConfigFile(); err != nil { + return err + } + } + + shimMgr, err := shim.NewSystemShimManager() + if err != nil { + return fmt.Errorf("failed to create system shim manager: %w", err) + } + + if err := shimMgr.Remove(); err != nil { + return fmt.Errorf("failed to remove system shims: %w", err) + } + + fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG system install removed") + return nil +} + +func errIfSystemInstallAllowed() error { + if runtime.GOOS != "linux" { + return usefulerror.NewUsefulError(). + WithCode(errcodes.UnsupportedPlatform). + WithHumanError("system install is only supported on Linux"). + WithHelp("Use `pmg setup install` without --system for per-user setup, or run on Linux"). + Wrap(errors.New("unsupported platform for --system")) + } + if setupGeteuid() != 0 { + return usefulerror.NewUsefulError(). + WithCode(errcodes.PermissionDenied). + WithHumanError("system install requires root"). + WithHelp("Re-run as root, e.g. `sudo pmg setup install --system`"). + Wrap(errors.New("not root")) + } + return nil +} diff --git a/cmd/setup/setup_test.go b/cmd/setup/setup_test.go new file mode 100644 index 0000000..d3bd2ca --- /dev/null +++ b/cmd/setup/setup_test.go @@ -0,0 +1,66 @@ +package setup + +import ( + "runtime" + "testing" + + "github.com/safedep/dry/usefulerror" + "github.com/safedep/pmg/errcodes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrIfSystemInstallAllowed(t *testing.T) { + orig := setupGeteuid + t.Cleanup(func() { setupGeteuid = orig }) + + setupGeteuid = func() int { return 0 } + err := errIfSystemInstallAllowed() + if runtime.GOOS == "linux" { + assert.NoError(t, err) + } else { + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + } + + setupGeteuid = func() int { return 1000 } + err = errIfSystemInstallAllowed() + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + if runtime.GOOS == "linux" { + assert.Equal(t, errcodes.PermissionDenied, usefulErr.Code()) + } else { + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + } +} + +func TestInstallSystemRequiresLinuxAndRoot(t *testing.T) { + orig := setupGeteuid + t.Cleanup(func() { + setupGeteuid = orig + setupInstallSystem = false + }) + + setupInstallSystem = true + setupGeteuid = func() int { return 0 } + + err := install() + if runtime.GOOS == "linux" { + if err != nil { + usefulErr, ok := usefulerror.AsUsefulError(err) + if ok { + assert.NotEqual(t, errcodes.UnsupportedPlatform, usefulErr.Code()) + assert.NotEqual(t, errcodes.PermissionDenied, usefulErr.Code()) + } + } + return + } + + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, errcodes.UnsupportedPlatform, usefulErr.Code()) +} diff --git a/config/config.go b/config/config.go index 20121eb..2ecc7d6 100644 --- a/config/config.go +++ b/config/config.go @@ -821,20 +821,59 @@ func WriteTemplateConfig() error { return nil } - configDir, err := configDir() - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) - } - - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - 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) + } + + return writeTemplateConfigFile(path) +} + +// 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) @@ -855,14 +894,7 @@ func WriteTemplateConfig() error { return nil } -// 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) - } - +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) } diff --git a/config/managed_config_test.go b/config/managed_config_test.go index caacc0b..3404d89 100644 --- a/config/managed_config_test.go +++ b/config/managed_config_test.go @@ -188,3 +188,19 @@ func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) { assert.NoFileExists(t, userFile, "per-user file should be removed") assert.FileExists(t, globalFile, "globally managed file must be left intact") } + +func TestWriteAndRemoveSystemTemplateConfig(t *testing.T) { + globalDir := t.TempDir() + useManagedConfigDir(t, globalDir) + + require.NoError(t, WriteSystemTemplateConfig()) + assert.FileExists(t, filepath.Join(globalDir, "config.yml")) + assert.Equal(t, globalDir, SystemConfigDir()) + assert.Equal(t, filepath.Join(globalDir, "config.yml"), globalConfigFilePath()) + + require.NoError(t, WriteSystemTemplateConfig()) + + require.NoError(t, RemoveSystemConfigFile()) + assert.NoFileExists(t, filepath.Join(globalDir, "config.yml")) + require.NoError(t, RemoveSystemConfigFile()) +} diff --git a/docs/config.md b/docs/config.md index e23594e..4224295 100644 --- a/docs/config.md +++ b/docs/config.md @@ -113,6 +113,10 @@ Whenever a global config file is present: By default a user can still override the global config's values at runtime through `PMG_*` environment variables and CLI flags. Enable lockdown to forbid that. +## System Install (Linux) + +See [system-install.md](./system-install.md) for `pmg setup install --system`: artifacts, Docker `ENV PATH`, what works vs what differs (aliases, config edit, logs, cloud, CA, sandbox), and permissions. + ### Lockdown Add `global_lockdown: true` to the global config to enforce it: diff --git a/docs/development.md b/docs/development.md index f887bd1..2c968e6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,3 +2,4 @@ - [User Interface](./ui.md) - [Steps to introduce a new Package Manager](./package-manager.md) +- [System install (Linux)](./system-install.md) diff --git a/docs/persistent-proxy.md b/docs/persistent-proxy.md index f9dd978..0a0b682 100644 --- a/docs/persistent-proxy.md +++ b/docs/persistent-proxy.md @@ -179,8 +179,8 @@ proxy regardless of how the package manager reported the failure. same state file is refused while one is running. - **System-level trust enforcement is out of scope.** The server relies on env var propagation. Enforcing interception for `sudo`-scrubbed environments (e.g. - via `iptables`) and system-wide install (`pmg setup install --system`) are - tracked separately. + via `iptables`) is tracked separately. For system-wide shell shims on Linux, + see [system-install.md](./system-install.md). ## References diff --git a/docs/specs/2026-07-10-system-install-spec.md b/docs/specs/2026-07-10-system-install-spec.md new file mode 100644 index 0000000..997ed60 --- /dev/null +++ b/docs/specs/2026-07-10-system-install-spec.md @@ -0,0 +1,68 @@ +# System Install (`pmg setup install --system`) Spec + +Date: 2026-07-10 +Issue: [#317](https://github.com/safedep/pmg/issues/317) +Prerequisite: [#323](https://github.com/safedep/pmg/pull/323) (`PMG_SHIM_PATH`) + +## Problem + +`pmg setup install` writes shims and shell integration relative to the invoking user's HOME. When root installs PMG in a golden Docker image (or shared VM) and a later `USER` / non-root account runs package managers, those users do not get interception. + +Host-side PMG (e.g. GitHub Actions) also cannot see package installs that happen inside `docker build`. + +## Goal + +Root can install PMG once for all users on a Linux host. Feature is a **general Linux system install**, motivated by Docker golden images but not Docker-specific. + +## Non-goals (v1) + +- macOS / Windows `--system` +- Persistent proxy baked into images +- In-place wrap of binaries under `/usr/local/bin` +- Shell function wrappers for venv PATH races +- Host PMG intercepting `docker build` from outside the image + +## Behaviour + +### Artifacts + +| Artifact | Path | +|----------|------| +| Config | `/etc/safedep/pmg/config.yml` | +| Shims | `/usr/local/lib/pmg/bin/` | +| Login PATH | `/etc/profile.d/pmg.sh` (prepends shim dir) | + +Per-user cache and event logs stay per-user. + +### CLI + +```bash +sudo pmg setup install --system +sudo pmg setup remove --system +sudo pmg setup remove --system --config-file # also remove system config +``` + +- Non-root `--system` → fail with `PermissionDenied` and sudo guidance +- Non-Linux `--system` → fail with `UnsupportedPlatform` +- Root without `--system` → warn, then per-user install into root's HOME +- System and per-user layers are independent + +### PATH + +- **VMs / login shells:** `/etc/profile.d/pmg.sh` prepends the shim dir +- **Docker `RUN`:** image must set `ENV PATH="/usr/local/lib/pmg/bin:$PATH"` (profile.d is not sourced) + +### Limitations + +- After `venv/bin/activate`, `pip` resolves to the venv binary and bypasses shims. Use `pmg pip …`. +- Child Dockerfiles that rewrite `PATH` without the shim dir lose interception. + +### Event logging + +If event log initialization fails (e.g. unusable HOME), PMG warns and continues instead of exiting. + +## Testing + +- Unit tests with overridden system paths +- Install/remove idempotence for system shims and profile.d +- Doctor/info report system scope diff --git a/docs/system-install.md b/docs/system-install.md new file mode 100644 index 0000000..a949f40 --- /dev/null +++ b/docs/system-install.md @@ -0,0 +1,124 @@ +# System Install (Linux) + +Use system install when one machine or image should protect every user account: shared VMs, golden Docker images, and similar setups. + +```bash +sudo pmg setup install --system +``` + +Requires Linux and root. To uninstall: + +```bash +sudo pmg setup remove --system +sudo pmg setup remove --system --config-file # also remove the system config file +``` + +Per-user `pmg setup install` remains available and does not conflict with a system install. + +## Files created + + +| Item | Path | +| --------------------- | ----------------------------- | +| Configuration | `/etc/safedep/pmg/config.yml` | +| Package-manager shims | `/usr/local/lib/pmg/bin` | +| Shell PATH snippet | `/etc/profile.d/pmg.sh` | + + +## Making shims visible on PATH + +System install writes shims to `/usr/local/lib/pmg/bin`. Processes only use them when that directory is on `PATH` ahead of the real `npm`, `pip`, and other package managers. + +### Linux VMs and login shells + +`pmg setup install --system` installs `/etc/profile.d/pmg.sh`, which prepends the shim directory for login shells. + +```bash +sudo pmg setup install --system +``` + +New login sessions pick this up automatically. For an already open shell, start a new login session or run: + +```bash +source /etc/profile.d/pmg.sh +``` + +Confirm with: + +```bash +which npm # should resolve under /usr/local/lib/pmg/bin +pmg setup doctor +``` + + +### Docker and container images + +Docker `RUN` does not load `/etc/profile.d`. After system install you **must** set `ENV PATH` so build steps and the runtime container see the shims: + +```dockerfile +FROM ubuntu:24.04 + +RUN curl -fsSL https://raw.githubusercontent.com/safedep/pmg/main/install.sh | sh \ + && pmg setup install --system + +# Required: profile.d is not sourced during docker build +ENV PATH="/usr/local/lib/pmg/bin:$PATH" + +# Optional: switch user; PATH from ENV still applies +USER appuser +RUN npm ci +``` + +Derived images inherit that `ENV`. Later `RUN npm install` / `RUN pip install` go through PMG for any `USER`. + +If a child Dockerfile sets `ENV PATH=...` again, keep `/usr/local/lib/pmg/bin` ahead of the real `npm`/`pip` directories. Leaving it out (or behind those toolchains) drops interception. + +## Configuration + +The system config file is authoritative for every user. A per-user `config.yml` is ignored while `/etc/safedep/pmg/config.yml` exists. + +`pmg config set` and `pmg config edit` fail under a system config. Update the file as root, or redeploy it through your image or configuration management. + +Optional lockdown (`global_lockdown: true`) is documented in [config.md](./config.md). + +## Limitations + +- **Virtualenv.** After `source .venv/bin/activate`, bare `pip` uses the venv binary and skips PMG shims. Call `pmg pip …` explicitly. +- **No shell aliases.** System install only installs PATH shims. There is no `~/.pmg.rc` alias layer. +- **Config changes.** `pmg config set` and `pmg config edit` are unavailable while the system config is active. Edit `/etc/safedep/pmg/config.yml` as root, or redeploy the file. +- **Custom sandbox** `policy_templates`**.** Relative paths in the system config resolve under each user's config directory, not `/etc/safedep/pmg`. Prefer absolute paths. +- `pmg sandbox allow`**.** Blocked when the system config sets `global_lockdown: true`. + + +## User data directories + +Shared policy lives under `/etc/safedep/pmg`. Runtime data stays per user: + + +| Data | Default location | +| --------------------- | ------------------------------------------------- | +| Event logs | `~/.config/safedep/pmg/logs/` | +| Cloud sync state | `~/.config/safedep/pmg/cloud-sync.db` | +| Cache | `~/.cache/safedep/pmg/` | +| Sandbox overlays | `~/.config/safedep/pmg/sandbox/overlays/` | +| Persistent CA keypair | `~/.config/safedep/pmg/ca-cert.pem`, `ca-key.pem` | + +You can relocate these with `PMG_CONFIG_DIR` and `PMG_CACHE_DIR`. + +The invoking user must be able to write their config directory. If they cannot, PMG skips event logging for that run (and prints a warning) and continues the package-manager command. Cloud sync also needs that directory to store pending events. + +In Docker images, avoid creating `/home//.config/safedep` as root during the build. Either fix ownership for the runtime user, or set `PMG_CONFIG_DIR` to a writable location. + +For cloud sync, enable cloud in the system config and provide credentials (`SAFEDEP_API_KEY` and `SAFEDEP_TENANT_ID`, or a keychain login on developer machines). + +## Certificates + +System install does not set up a MITM certificate authority. For npm and pip on Linux, PMG's default ephemeral CA and environment-variable injection are enough. + +To install a persistent CA into the OS trust store, use a separate command: + +```bash +pmg setup cert install --system +``` + +Run that as your normal user. Details are in [cert.md](./cert.md). diff --git a/go.work.sum b/go.work.sum index 92b2b74..9fe44cb 100644 --- a/go.work.sum +++ b/go.work.sum @@ -263,6 +263,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= gorm.io/plugin/prometheus v0.1.0/go.mod h1:5nrc/JrWCUNoDXCY4eOae/FK/J5WjQ0axXuFusCzdTc= diff --git a/internal/shim/shim.go b/internal/shim/shim.go index beda28e..90c5b06 100644 --- a/internal/shim/shim.go +++ b/internal/shim/shim.go @@ -18,6 +18,11 @@ type ShimConfig struct { PMGBin string PackageManagers []string Shells []alias.Shell + // SkipShellRc skips per-user shell rc PATH edits. Used by system install, + // which relies on /etc/profile.d or ENV PATH instead. + SkipShellRc bool + // ManageProfile writes and removes /etc/profile.d/pmg.sh with Install/Remove. + ManageProfile bool } type ShimManager struct { @@ -68,6 +73,16 @@ func (m *ShimManager) Install() error { } } + if m.config.ManageProfile { + if err := writeSystemProfile(); err != nil { + return fmt.Errorf("failed to write system profile: %w", err) + } + } + + if m.config.SkipShellRc { + return nil + } + if err := m.addPathToShells(); err != nil { return fmt.Errorf("failed to update shell configs: %w", err) } @@ -80,6 +95,16 @@ func (m *ShimManager) Remove() error { log.Warnf("Warning: failed to remove shim directory: %v", err) } + if m.config.ManageProfile { + if err := removeSystemProfile(); err != nil { + return fmt.Errorf("failed to remove system profile: %w", err) + } + } + + if m.config.SkipShellRc { + return nil + } + if err := m.removePathFromShells(); err != nil { return fmt.Errorf("failed to clean shell configs: %w", err) } @@ -207,3 +232,13 @@ func (m *ShimManager) removePathFromShells() error { return nil } + +// UserShimsInstalled reports whether the per-user shim directory (~/.pmg/bin) +// contains at least one shim script. +func UserShimsInstalled() bool { + homeDir, err := os.UserHomeDir() + if err != nil { + return false + } + return shimsPresent(filepath.Join(homeDir, ".pmg", "bin")) +} diff --git a/internal/shim/system.go b/internal/shim/system.go new file mode 100644 index 0000000..1d31ae0 --- /dev/null +++ b/internal/shim/system.go @@ -0,0 +1,119 @@ +package shim + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/safedep/pmg/internal/alias" +) + +const ( + defaultSystemBinDir = "/usr/local/lib/pmg/bin" + defaultSystemProfilePath = "/etc/profile.d/pmg.sh" + systemProfileMarker = "PMG system shims" +) + +// systemBinDirOverride and systemProfilePathOverride replace the OS-level +// system install paths. They exist only for tests within this package. There +// is intentionally no env var or flag for them. +var ( + systemBinDirOverride string + systemProfilePathOverride string +) + +// SystemBinDir returns the directory for system-wide PMG shims. +func SystemBinDir() string { + if systemBinDirOverride != "" { + return systemBinDirOverride + } + return defaultSystemBinDir +} + +// SystemProfilePath returns the path of the system profile.d snippet. +func SystemProfilePath() string { + if systemProfilePathOverride != "" { + return systemProfilePathOverride + } + return defaultSystemProfilePath +} + +// NewSystemShimManager creates a shim manager for system-wide install: shims +// under SystemBinDir, no per-user rc edits, and /etc/profile.d management. +func NewSystemShimManager() (*ShimManager, error) { + aliasCfg := alias.DefaultConfig() + pmgBin, err := currentExecutable() + if err != nil { + return nil, err + } + + return &ShimManager{ + config: ShimConfig{ + BinDir: SystemBinDir(), + PMGBin: pmgBin, + PackageManagers: aliasCfg.PackageManagers, + SkipShellRc: true, + ManageProfile: true, + }, + }, nil +} + +// SystemShimsInstalled reports whether the system shim directory contains at +// least one shim script. +func SystemShimsInstalled() bool { + return shimsPresent(SystemBinDir()) +} + +func shimsPresent(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() { + return true + } + } + return false +} + +// SystemProfileInstalled reports whether the system profile snippet exists and +// contains the PMG marker. +func SystemProfileInstalled() bool { + data, err := os.ReadFile(SystemProfilePath()) + if err != nil { + return false + } + return strings.Contains(string(data), systemProfileMarker) +} + +func writeSystemProfile() error { + binDir := SystemBinDir() + path := SystemProfilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create profile.d directory: %w", err) + } + + if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), systemProfileMarker) { + return nil + } + + content := fmt.Sprintf(`# %s - managed by pmg setup install --system +# remove by running: pmg setup remove --system +export PATH="%s:$PATH" +`, systemProfileMarker, binDir) + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write system profile %s: %w", path, err) + } + return nil +} + +func removeSystemProfile() error { + path := SystemProfilePath() + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove system profile %s: %w", path, err) + } + return nil +} diff --git a/internal/shim/system_test.go b/internal/shim/system_test.go new file mode 100644 index 0000000..bd70f8e --- /dev/null +++ b/internal/shim/system_test.go @@ -0,0 +1,73 @@ +package shim + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func useSystemPaths(t *testing.T, dir string) { + t.Helper() + systemBinDirOverride = filepath.Join(dir, "bin") + systemProfilePathOverride = filepath.Join(dir, "profile.d", "pmg.sh") + t.Cleanup(func() { + systemBinDirOverride = "" + systemProfilePathOverride = "" + }) +} + +func TestSystemShimManagerInstallAndRemove(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + + mgr, err := NewSystemShimManager() + require.NoError(t, err) + assert.True(t, mgr.config.SkipShellRc) + assert.True(t, mgr.config.ManageProfile) + assert.Equal(t, SystemBinDir(), mgr.GetBinDir()) + + require.NoError(t, mgr.Install()) + assert.True(t, SystemShimsInstalled()) + assert.True(t, SystemProfileInstalled()) + + npmShim := filepath.Join(SystemBinDir(), "npm") + content, err := os.ReadFile(npmShim) + require.NoError(t, err) + assert.Contains(t, string(content), "export PMG_SHIM_PATH") + + profile, err := os.ReadFile(SystemProfilePath()) + require.NoError(t, err) + assert.Contains(t, string(profile), mgr.GetBinDir()) + assert.Contains(t, string(profile), systemProfileMarker) + + require.NoError(t, mgr.Install()) + profile2, err := os.ReadFile(SystemProfilePath()) + require.NoError(t, err) + assert.Equal(t, string(profile), string(profile2)) + + require.NoError(t, mgr.Remove()) + assert.False(t, SystemShimsInstalled()) + assert.False(t, SystemProfileInstalled()) + require.NoError(t, mgr.Remove()) +} + +func TestSystemShimManagerDoesNotTouchUserRc(t *testing.T) { + root := t.TempDir() + useSystemPaths(t, root) + + home := t.TempDir() + bashrc := filepath.Join(home, ".bashrc") + require.NoError(t, os.WriteFile(bashrc, []byte("# user bashrc\n"), 0o644)) + + mgr, err := NewSystemShimManager() + require.NoError(t, err) + mgr.config.HomeDir = home + require.NoError(t, mgr.Install()) + + content, err := os.ReadFile(bashrc) + require.NoError(t, err) + assert.Equal(t, "# user bashrc\n", string(content)) +} diff --git a/internal/ui/info.go b/internal/ui/info.go index d4b2ee7..d69b6f1 100644 --- a/internal/ui/info.go +++ b/internal/ui/info.go @@ -32,3 +32,13 @@ func PrintSetupInstallCmdInfo(aliasPath, shimBinDir, configPath string) { fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configPath))) fmt.Printf(" %s\n", Colors.Dim("Restart your terminal for changes to take effect")) } + +func PrintSetupSystemInstallCmdInfo(shimBinDir, configDir, profilePath string) { + fmt.Printf("%s %s\n", Colors.Green("✓"), "PMG system install completed") + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Shims: %s", shimBinDir))) + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configDir))) + fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Profile: %s", profilePath))) + fmt.Printf("\n%s For Docker builds (RUN does not source profile.d), add:\n", Colors.Dim("ℹ")) + fmt.Printf(" %s\n", Colors.Bold(fmt.Sprintf(`ENV PATH="%s:$PATH"`, shimBinDir))) + fmt.Printf("%s Login shells pick up PATH from profile.d. After venv activate, use `pmg pip`.\n", Colors.Dim("ℹ")) +} diff --git a/main.go b/main.go index 3d42efb..ff72803 100644 --- a/main.go +++ b/main.go @@ -103,7 +103,9 @@ func main() { } if eventlogErr != nil { - ui.Fatalf("failed to initialize event logging: %v", eventlogErr) + // Soft-fail: unusable HOME (e.g. system accounts, some containers) + // must not prevent package-manager commands from running. + log.Warnf("failed to initialize event logging: %v", eventlogErr) } if err := audit.Initialize(config.Get()); err != nil {