Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f6d0fc
feat: add Linux system-wide setup
Sahilb315 Jul 10, 2026
7922606
chore: keep local design documents untracked
Sahilb315 Jul 10, 2026
ffd0e7e
fix: harden and simplify Linux system install
Sahilb315 Jul 10, 2026
8d1a4b6
fix: clarify system-install doctor alias and shim path checks
Sahilb315 Jul 13, 2026
fdd1aea
fix: tighten event-log soft-fail warning prefix
Sahilb315 Jul 13, 2026
5d710a7
ci: add Linux system-install e2e and pin pnpm for add flake
Sahilb315 Jul 13, 2026
2b53abb
chore: bump packageManager to pnpm 11.10.0 for e2e
Sahilb315 Jul 13, 2026
37eed63
ci: use npm init for pnpm e2e to avoid integrity crash
Sahilb315 Jul 13, 2026
0d0399f
chore: revert packageManager pin to pnpm 11.1.3
Sahilb315 Jul 13, 2026
1c9b16f
fix: harden system-install review findings
Sahilb315 Jul 13, 2026
b1aa217
fix: harden doctor PATH checks and attribute cloud events by OS user
Sahilb315 Jul 13, 2026
1276a1e
fix: address system-install review findings
Sahilb315 Jul 13, 2026
1b17eb2
ci: normalize /usr/local/bin perms before system-install e2e
Sahilb315 Jul 13, 2026
479f546
fix: actionable remedy for root-created per-user config dir
Sahilb315 Jul 13, 2026
de0fa41
fix: resolve per-user paths from root's own home when running as root
Sahilb315 Jul 13, 2026
cd9b45b
fix: triage the unwritable config dir remedy by cause
Sahilb315 Jul 13, 2026
e0580b1
ci: pin XDG_CONFIG_HOME for the cross-user e2e step; terse doctor fix
Sahilb315 Jul 13, 2026
ad16d8c
fix: adapt event-log error to the two-value remedy signature
Sahilb315 Jul 13, 2026
297f516
fix: fall back to env path resolution when root has no passwd entry
Sahilb315 Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion .github/workflows/pmg-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,10 @@ jobs:
run: |
echo "Testing PNPM single package installation..."
PNPM_TESTDIR=$(mktemp -d) && cd "$PNPM_TESTDIR"
pmg --proxy-mode=false pnpm init
# Avoid `pnpm init`: it writes devEngines.packageManager with
# onFail:download, and the following `pnpm add` then crashes with
# "Cannot use 'in' operator to search for 'integrity' in undefined".
npm init -y
pmg --proxy-mode=false pnpm add express@5.2.1
pmg --proxy-mode=false pnpm add lodash@4.17.21

Expand Down Expand Up @@ -874,3 +877,111 @@ jobs:

- name: Run Package Manager E2E Test
run: pmg --sandbox --sandbox-enforce npm exec -- node test/pm-e2e.js

# Linux system-wide install: root install, ENV PATH (Docker-style), non-root user,
# managed config, and remove. Profile.d login shells are covered by sourcing the snippet.
e2e-system-install:
name: PMG E2E - System Install (Linux)
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- name: Checkout Source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
cache: true

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"

- name: Build PMG
run: make

- name: Reject private PMG binary for system install
run: |
sudo mkdir -p /root/pmg-private
sudo cp bin/pmg /root/pmg-private/pmg
sudo chmod 700 /root/pmg-private /root/pmg-private/pmg
if sudo /root/pmg-private/pmg setup install --system; then
echo "ERROR: system install accepted a non-world-executable binary"
exit 1
fi
echo "SUCCESS: private binary rejected"

- name: Install PMG system-wide
run: |
sudo install -m 755 bin/pmg /usr/local/bin/pmg
sudo pmg setup install --system

- name: Verify system install artifacts
run: |
test -f /etc/safedep/pmg/config.yml
test -f /etc/profile.d/pmg.sh
grep -q '/usr/local/lib/pmg/bin' /etc/profile.d/pmg.sh
for shim in npm pip pip3 pipx pnpm bun uv uvx yarn poetry npx pnpx; do
test -x "/usr/local/lib/pmg/bin/$shim" || { echo "Missing shim: $shim"; exit 1; }
done

- name: PATH and profile.d activate shims
run: |
# Docker-style: non-login shells need PATH (or source profile.d)
export PATH="/usr/local/lib/pmg/bin:$PATH"
which npm | grep -q '/usr/local/lib/pmg/bin/npm'
source /etc/profile.d/pmg.sh
which npm | grep -q '/usr/local/lib/pmg/bin/npm'

- name: Managed config refuses CLI mutation
run: |
if pmg config set dependency_cooldown.days 7; then
echo "ERROR: config set should fail under system config"
exit 1
fi
if sudo pmg config set dependency_cooldown.days 7; then
echo "ERROR: config set should fail under system config even as root"
exit 1
fi
echo "SUCCESS: managed config is locked"

- name: Doctor reports system install state
run: |
export PATH="/usr/local/lib/pmg/bin:$PATH"
out=$(pmg setup doctor 2>&1 || true)
echo "$out"
echo "$out" | grep -q 'No aliases (system install)'
echo "$out" | grep -q 'System shim directory is in PATH'

- name: Non-root user interception via system shims
run: |
sudo useradd -m pmgtest || true
# Pass runner PATH so setup-node's npm remains visible after FilterPMGFromPath.
sudo -u pmgtest env "PATH=/usr/local/lib/pmg/bin:$PATH" HOME=/home/pmgtest bash -lc '
set -euo pipefail
which npm | grep -q /usr/local/lib/pmg/bin/npm
mkdir -p "$HOME/sys-e2e" && cd "$HOME/sys-e2e"
npm init -y
if npm install --no-cache --prefer-online safedep-test-pkg@0.1.3; then
echo "ERROR: safedep-test-pkg was not blocked for non-root user"
exit 1
fi
if [ -d node_modules/safedep-test-pkg ]; then
echo "ERROR: safedep-test-pkg present in node_modules"
exit 1
fi
echo "SUCCESS: non-root user blocked malicious package via system shims"
'

- name: Remove system install
run: |
sudo pmg setup remove --system --config-file
test ! -e /etc/profile.d/pmg.sh
test ! -e /etc/safedep/pmg/config.yml
test ! -d /usr/local/lib/pmg/bin
echo "SUCCESS: system install removed"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
70 changes: 54 additions & 16 deletions cmd/setup/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"slices"

"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/alias"
Expand All @@ -31,6 +30,8 @@ const (
checkProtectionNpm = "protection-npm"
checkProtectionPip = "protection-pip"
checkCA = "ca-cert"

aliasesInstalledMessage = "Shell aliases installed"
)

func NewDoctorCommand() *cobra.Command {
Expand Down Expand Up @@ -91,10 +92,16 @@ 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,
Status: doctor.StatusWarn,
Message: "Event log directory not found",
}
}
Expand Down Expand Up @@ -130,22 +137,34 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
Message: fmt.Sprintf("Could not determine alias status: %v", err),
}
}
if !installed {
if installed {
return doctor.CheckResult{
Status: doctor.StatusFail,
Message: "Aliases not installed",
Status: doctor.StatusPass,
Message: aliasesInstalledMessage,
}
}
if shim.SystemShimsInstalled() {
return doctor.CheckResult{
Status: doctor.StatusPass,
Message: "No aliases (system install)",
}
}
return doctor.CheckResult{
Status: doctor.StatusPass,
Message: "Shell aliases installed",
Status: doctor.StatusFail,
Message: "Aliases not installed",
}
},
},
{
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{
Expand All @@ -171,20 +190,26 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
Name: checkShimInPath,
Category: "Shell Integration",
Run: func() doctor.CheckResult {
sm, err := shim.NewDefaultShimManager()
if err != nil {
pathEntries := filepath.SplitList(os.Getenv("PATH"))
systemDir := shim.SystemBinDir()
if shim.SystemShimsInstalled() && pathContainsDir(pathEntries, systemDir) {
return doctor.CheckResult{
Status: doctor.StatusWarn,
Message: fmt.Sprintf("Could not check shims: %v", err),
Status: doctor.StatusPass,
Message: "System shim directory is in PATH",
Comment thread
Sahilb315 marked this conversation as resolved.
Outdated
}
}
shimDir := sm.GetBinDir()
if slices.Contains(filepath.SplitList(os.Getenv("PATH")), shimDir) {
if userDir, err := shim.UserBinDir(); err == nil && pathContainsDir(pathEntries, userDir) {
return doctor.CheckResult{
Status: doctor.StatusPass,
Message: "Shim directory is in PATH",
}
}
if shim.SystemShimsInstalled() {
return doctor.CheckResult{
Status: doctor.StatusFail,
Message: "System shim directory not in PATH",
}
}
return doctor.CheckResult{
Status: doctor.StatusFail,
Message: "Shim directory not in PATH",
Expand Down Expand Up @@ -275,6 +300,19 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
return doctor.RunChecks(checks)
}

func pathContainsDir(pathEntries []string, dir string) bool {
if dir == "" {
return false
}
cleanDir := filepath.Clean(dir)
for _, entry := range pathEntries {
if filepath.Clean(entry) == cleanDir {
return true
}
}
return false
}

func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult {
if !isInterceptionActive(coreResults) {
var results []doctor.CheckResult
Expand Down Expand Up @@ -306,10 +344,10 @@ func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult

func isInterceptionActive(coreResults []doctor.CheckResult) bool {
for _, r := range coreResults {
if r.Name == checkShellAliases && r.Status == doctor.StatusPass {
if r.Name == checkShimInPath && r.Status == doctor.StatusPass {
return true
}
if r.Name == checkShimInPath && r.Status == doctor.StatusPass {
if r.Name == checkShellAliases && r.Status == doctor.StatusPass && r.Message == aliasesInstalledMessage {
Comment thread
Sahilb315 marked this conversation as resolved.
Outdated
return true
}
}
Expand All @@ -336,7 +374,7 @@ var checkFixes = map[string]string{
checkEventLogDir: "pmg setup install",
checkShellAliases: "pmg setup install",
checkShimDirectory: "pmg setup install",
checkShimInPath: "Restart shell or source config",
checkShimInPath: "Restart shell or source profile",
checkProxyMode: "Set proxy.enabled: true in config",
checkSandbox: "Set sandbox.enabled: true in config",
checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config",
Expand Down
32 changes: 32 additions & 0 deletions cmd/setup/doctor_system_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package setup

import (
"testing"

"github.com/safedep/pmg/internal/doctor"
"github.com/stretchr/testify/assert"
)

func TestPathContainsDir(t *testing.T) {
assert.True(t, pathContainsDir([]string{"/usr/local/lib/pmg/bin/"}, "/usr/local/lib/pmg/bin"))
assert.False(t, pathContainsDir([]string{"/usr/local/bin"}, "/usr/local/lib/pmg/bin"))
assert.False(t, pathContainsDir([]string{"/usr/bin"}, ""))
}

func TestSystemInstallAliasesPassDoesNotActivateInterception(t *testing.T) {
results := []doctor.CheckResult{
{Name: checkShellAliases, Status: doctor.StatusPass, Message: "No aliases (system install)"},
{Name: checkShimInPath, Status: doctor.StatusFail},
}

assert.False(t, isInterceptionActive(results))
}

func TestAliasesInstalledActivatesInterception(t *testing.T) {
results := []doctor.CheckResult{
{Name: checkShellAliases, Status: doctor.StatusPass, Message: aliasesInstalledMessage},
{Name: checkShimInPath, Status: doctor.StatusFail},
}

assert.True(t, isInterceptionActive(results))
}
7 changes: 7 additions & 0 deletions cmd/setup/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -78,6 +79,12 @@ func executeSetupInfo() error {

shellEntries["Detected Shell"] = shell
shellEntries["Alias Installed"] = strconv.FormatBool(isInstalled)
shellEntries["User Shims"] = strconv.FormatBool(shim.UserShimsInstalled())
if shim.SystemShimsInstalled() {
shellEntries["System Shims"] = shim.SystemBinDir()
} else {
shellEntries["System Shims"] = "not installed"
}
ui.PrintInfoSection("Shell Integration", shellEntries)

// Security section
Expand Down
Loading
Loading