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>
This commit is contained in:
Sahilb315
2026-07-11 02:19:59 +05:30
co-authored by Cursor
parent d3e656edcd
commit 8f6d0fcce0
19 changed files with 915 additions and 66 deletions
@@ -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 roots 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
+2
View File
@@ -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:
+40 -8
View File
@@ -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",
}
+7
View File
@@ -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
+131 -38
View File
@@ -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
}
+66
View File
@@ -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())
}
+49 -17
View File
@@ -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)
}
+16
View File
@@ -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())
}
+4
View File
@@ -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:
+1
View File
@@ -2,3 +2,4 @@
- [User Interface](./ui.md)
- [Steps to introduce a new Package Manager](./package-manager.md)
- [System install (Linux)](./system-install.md)
+2 -2
View File
@@ -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
@@ -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/<pm>` |
| 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
+124
View File
@@ -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/<user>/.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).
+1
View File
@@ -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=
+35
View File
@@ -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"))
}
+119
View File
@@ -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
}
+73
View File
@@ -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))
}
+10
View File
@@ -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(""))
}
+3 -1
View File
@@ -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 {