From 5597fd20aa9af9e96e043446ae71857ab5ecf308 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Thu, 25 Jun 2026 12:50:55 +0200 Subject: [PATCH 1/5] [PPSC-1065] feat(supply-chain): add PowerShell support to init Extend `supply-chain init` RC mode to PowerShell so Windows/PowerShell users get install-time package-age enforcement, not just scanning. - DetectShells: append a PowerShell pass (pwsh on macOS/Linux; pwsh + Windows PowerShell 5.1 on Windows). An entry qualifies if its executable is on PATH or its profile already exists. - powershellProfiles / resolveWindowsDocumentsDir: resolve the CurrentUserAllHosts profile path, handling OneDrive redirection. - generatePowerShellWrapper + shellQuotePowerShell: recursion-safe via `Get-Command -CommandType Application`, fail-closed (warn on real stderr, run the real PM unwrapped), skips dotted pip variants. - ShellReloadCommand / IsPowerShell helpers; cmd-layer string and guidance updates (init error + help, dotted-variant note, uninit help, status suppression comment). Inject/remove/status plumbing and the WrappedPMs regex are shell-agnostic, so they inherit PowerShell with no change. RC-mode only; --mode env and the uvToolReceipts Windows path fix are out of scope. Verified end-to-end on macOS via a pwsh stub; Windows-gated tests run on the existing windows-latest CI matrix entry. --- internal/cmd/supply_chain_init.go | 47 ++++- internal/cmd/supply_chain_init_test.go | 66 +++++++ internal/cmd/supply_chain_status.go | 2 +- internal/cmd/supply_chain_uninit.go | 2 +- internal/supplychain/shell.go | 162 ++++++++++++++++- internal/supplychain/shell_test.go | 242 ++++++++++++++++++++++++- 6 files changed, 512 insertions(+), 9 deletions(-) diff --git a/internal/cmd/supply_chain_init.go b/internal/cmd/supply_chain_init.go index 128dfed..e66a2ea 100644 --- a/internal/cmd/supply_chain_init.go +++ b/internal/cmd/supply_chain_init.go @@ -37,7 +37,8 @@ registry responses. poetry, pipenv, pdm, mvn, and gradle use a pre-install check that blocks the build if violations are found. Four modes are available: - rc — Inject shell functions into ~/.bashrc / ~/.zshrc (default, interactive) + rc — Inject shell functions into ~/.bashrc / ~/.zshrc / fish config / + PowerShell profile (default, interactive) env — Print an eval command for CI or manual sourcing npmrc — Add a marker comment to .npmrc (the registry override itself is set dynamically by 'supply-chain wrap'; use with the rc or env modes) @@ -297,6 +298,35 @@ func summarizeDetectedPMs(s *output.Styles, pms []string) string { return strings.Join(parts, ", ") } +// powerShellSkippedDottedPMs returns the package-manager names in pms that a +// PowerShell wrapper cannot define, because PowerShell function names may not +// contain a dot (e.g. pip3.12). It returns nil unless at least one detected +// shell is PowerShell — the limitation only matters when a PowerShell wrapper is +// actually being written, and on a bash/zsh/fish-only machine the dotted variant +// is wrapped normally. The init flow uses this to print a one-line note so a +// PowerShell user understands pip3.12 was skipped while pip and pip3 still cover +// the common case (the runtime canonicalizes every pip variant to PyPI anyway). +func powerShellSkippedDottedPMs(pms []string, shells []supplychain.Shell) []string { + hasPowerShell := false + for _, sh := range shells { + if supplychain.IsPowerShell(sh.Name) { + hasPowerShell = true + break + } + } + if !hasPowerShell { + return nil + } + + var dotted []string + for _, pm := range pms { + if strings.Contains(pm, ".") { + dotted = append(dotted, pm) + } + } + return dotted +} + // promptYesNo asks the user a yes/no question and reports their answer. // // On an interactive terminal it renders a themed huh.Confirm, matching the @@ -443,7 +473,7 @@ func runInitRC(pms []string) error { shells := supplychain.DetectShells() if len(shells) == 0 { - return fmt.Errorf("no supported shells detected (bash, zsh, or fish)") + return fmt.Errorf("no supported shells detected (bash, zsh, fish, or PowerShell)") } // Short-circuit: if every detected shell already has the exact wrapper we @@ -480,6 +510,17 @@ func runInitRC(pms []string) error { fmt.Fprintf(os.Stderr, "%s %s\n\n", s.MutedText.Render("Package manager(s) to wrap:"), summarizeDetectedPMs(s, pms)) + // PowerShell cannot define functions whose name contains a dot, so a versioned + // pip variant (pip3.12) is skipped in the PowerShell profile. Surface that as a + // single muted line — only when a PowerShell shell is among those detected and a + // dotted variant is actually present — so the user knows the skip is intentional + // and that pip/pip3 still cover the common case. + if dotted := powerShellSkippedDottedPMs(pms, shells); len(dotted) > 0 { + fmt.Fprintf(os.Stderr, "%s\n\n", s.MutedText.Render(fmt.Sprintf( + "Note: %s can't be wrapped in PowerShell (dotted function names are unsupported); pip and pip3 are wrapped instead.", + strings.Join(dotted, ", ")))) + } + // Preview each distinct wrapper. bash/zsh share the posix wrapper while fish // uses different syntax, so group shells by the wrapper they produce to keep // the preview accurate when multiple shells are detected. @@ -524,7 +565,7 @@ func runInitRC(pms []string) error { fmt.Fprintf(os.Stderr, "\n%s Restart your shell or run:\n", s.SuccessText.Render("Done!")) for _, sh := range shells { - fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render("source "+sh.RCFile)) + fmt.Fprintf(os.Stderr, " %s\n", s.Bold.Render(supplychain.ShellReloadCommand(sh.Name, sh.RCFile))) } policy := resolveWrapPolicy() fmt.Fprintf(os.Stderr, "\n%s block packages published less than %s ago\n", s.MutedText.Render("Policy:"), policy.MinReleaseAge) diff --git a/internal/cmd/supply_chain_init_test.go b/internal/cmd/supply_chain_init_test.go index 1a01a25..146feb9 100644 --- a/internal/cmd/supply_chain_init_test.go +++ b/internal/cmd/supply_chain_init_test.go @@ -404,6 +404,72 @@ func TestSummarizeDetectedPMs(t *testing.T) { } } +// TestPowerShellSkippedDottedPMs verifies the helper that drives the init-time +// note about pip variants PowerShell can't wrap: it reports dotted PM names only +// when a PowerShell shell is among those detected, and stays silent on a +// POSIX/fish-only machine (where dotted variants wrap fine). +func TestPowerShellSkippedDottedPMs(t *testing.T) { + pwsh := []supplychain.Shell{{Name: "pwsh", RCFile: "/p/profile.ps1"}} + posix := []supplychain.Shell{{Name: "bash", RCFile: "/h/.bashrc"}} + mixed := []supplychain.Shell{ + {Name: "zsh", RCFile: "/h/.zshrc"}, + {Name: "powershell", RCFile: "/p/profile.ps1"}, + } + + tests := []struct { + name string + pms []string + shells []supplychain.Shell + want []string + }{ + { + name: "powershell detected with a dotted variant", + pms: []string{"npm", "pip", "pip3", "pip3.12"}, + shells: pwsh, + want: []string{"pip3.12"}, + }, + { + name: "multiple dotted variants are all reported", + pms: []string{"pip3.11", "pip3.12"}, + shells: pwsh, + want: []string{"pip3.11", "pip3.12"}, + }, + { + name: "powershell among mixed shells still reports", + pms: []string{"pip", "pip3.10"}, + shells: mixed, + want: []string{"pip3.10"}, + }, + { + name: "no powershell means no note even with a dotted variant", + pms: []string{"pip", "pip3.12"}, + shells: posix, + want: nil, + }, + { + name: "powershell but no dotted variant is silent", + pms: []string{"npm", "pip", "pip3"}, + shells: pwsh, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := powerShellSkippedDottedPMs(tt.pms, tt.shells) + if len(got) != len(tt.want) { + t.Fatalf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("powerShellSkippedDottedPMs = %v, want %v", got, tt.want) + break + } + } + }) + } +} + func TestExtractScope(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/supply_chain_status.go b/internal/cmd/supply_chain_status.go index 4d060ec..2ebb400 100644 --- a/internal/cmd/supply_chain_status.go +++ b/internal/cmd/supply_chain_status.go @@ -375,7 +375,7 @@ func runSupplyChainStatusJSON(dir string) error { result.Ecosystems = []statusEcosystemJSON{} } - // armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish); the result set is bounded by a fixed allowlist, not by attacker input + // armis:ignore cwe:770 reason:DetectShells returns at most one entry per known shell (bash/zsh/fish/powershell); the result set is bounded by a fixed allowlist, not by attacker input shells := supplychain.DetectShells() for _, sh := range shells { active := supplychain.HasInjection(sh.RCFile) diff --git a/internal/cmd/supply_chain_uninit.go b/internal/cmd/supply_chain_uninit.go index 8768df1..737f32c 100644 --- a/internal/cmd/supply_chain_uninit.go +++ b/internal/cmd/supply_chain_uninit.go @@ -19,7 +19,7 @@ var scUninitCmd = &cobra.Command{ Short: "Remove shell wrapper functions injected by supply-chain init", Long: `Remove the changes made by 'armis-cli supply-chain init'. -This scans your shell RC files (bashrc, zshrc, fish config) for armis-cli supply-chain +This scans your shell RC files (bashrc, zshrc, fish config, PowerShell profile) for armis-cli supply-chain blocks and removes them, and strips the marker comment from a project .npmrc in the current directory if present. Your package manager will return to its normal behavior. diff --git a/internal/supplychain/shell.go b/internal/supplychain/shell.go index 1906129..2d8b328 100644 --- a/internal/supplychain/shell.go +++ b/internal/supplychain/shell.go @@ -30,9 +30,11 @@ const goosWindows = "windows" // Shell name constants used by DetectShells and GenerateWrapper. const ( - shellBash = "bash" - shellZsh = "zsh" - shellFish = "fish" + shellBash = "bash" + shellZsh = "zsh" + shellFish = "fish" + shellPwsh = "pwsh" // PowerShell 7+ (cross-platform) + shellWindowsPowerShell = "powershell" // Windows PowerShell 5.1 ) // pipExeBase is the bare pip executable name. DetectPipVariants falls back to @@ -111,19 +113,115 @@ func DetectShells() []Shell { } } + // PowerShell detection runs as a separate pass. On a POSIX machine $SHELL is + // never "pwsh" and the profile rarely exists, so this yields nothing unless + // the user actually installed PowerShell; on Windows $SHELL is unset and the + // POSIX RC files above don't exist, so the loop above contributes nothing and + // this pass is the only source of shells. Inclusion rule: a PowerShell entry + // qualifies if its executable is on PATH (the analogue of the POSIX + // current-shell check) OR its profile already exists (the analogue of the + // fileExists check) — the profile commonly does not exist until first init. + for _, ps := range powershellProfiles(home) { + if IsOnPath(ps.Name) || fileExists(ps.RCFile) { + shells = append(shells, ps) + } + } + return shells } +// powershellProfiles returns the candidate PowerShell profile targets for the +// current OS. The profile path is the CurrentUserAllHosts profile — host- +// agnostic so the wrapper fires in both the console host and the VS Code +// integrated terminal. Go cannot read PowerShell's own $PROFILE (that requires +// a PowerShell process), so the path is reconstructed from well-known layouts. +func powershellProfiles(home string) []Shell { + if runtime.GOOS != goosWindows { + // On macOS/Linux only PowerShell 7+ (pwsh) exists; its CurrentUserAllHosts + // profile lives under ~/.config/powershell. Build path + Shell on one line + // so the suppression lands on the single taint sink (see Windows branch). + // armis:ignore cwe:22 cwe:73 reason:home is the current user's own $HOME joined with hardcoded path segments; configuring the user's own shell profile is the purpose of `supply-chain init` + return []Shell{{Name: shellPwsh, RCFile: filepath.Join(home, ".config", "powershell", "profile.ps1")}} + } + + // On Windows both editions can be present: PowerShell 7+ (pwsh) and Windows + // PowerShell 5.1 (powershell). They use distinct Documents subdirectories. + // List pwsh first so it is preferred when both are installed. The filepath.Join + // is kept inline in the Shell literal on a single line so the taint flow + // (docs → Join → Shell) has one sink line the suppression sits directly above. + docs := resolveWindowsDocumentsDir(home) + // armis:ignore cwe:22 cwe:73 reason:docs derives from the current user's own $HOME/Documents (or OneDrive redirect) joined with hardcoded segments; configuring the user's own PowerShell profile is the purpose of `supply-chain init` + pwsh := Shell{Name: shellPwsh, RCFile: filepath.Join(docs, "PowerShell", "profile.ps1")} + // armis:ignore cwe:22 cwe:73 reason:docs derives from the current user's own $HOME/Documents (or OneDrive redirect) joined with hardcoded segments; configuring the user's own PowerShell profile is the purpose of `supply-chain init` + win := Shell{Name: shellWindowsPowerShell, RCFile: filepath.Join(docs, "WindowsPowerShell", "profile.ps1")} + return []Shell{pwsh, win} +} + +// resolveWindowsDocumentsDir locates the user's Documents directory, which is +// where PowerShell anchors the CurrentUserAllHosts profile. A native Go process +// cannot read PowerShell's $PROFILE, and OneDrive folder redirection commonly +// moves Documents out of %USERPROFILE%, so the path is resolved heuristically: +// +// 1. /Documents if it already exists (the un-redirected default). +// 2. else /Documents for the first OneDrive env var that yields +// an existing directory (OneDriveConsumer, OneDrive, OneDriveCommercial). +// 3. else /Documents as a not-yet-existing fallback — injectIntoFile's +// os.MkdirAll creates the parent on write, so a missing target is fine. +func resolveWindowsDocumentsDir(home string) string { + // armis:ignore cwe:22 cwe:73 reason:home is the current user's own $HOME joined with the hardcoded "Documents" segment; configuring the user's own profile location is the purpose of `supply-chain init` + defaultDocs := filepath.Join(home, "Documents") + if fileExists(defaultDocs) { + return defaultDocs + } + + for _, env := range []string{"OneDriveConsumer", "OneDrive", "OneDriveCommercial"} { + root := os.Getenv(env) + if root == "" { + continue + } + // armis:ignore cwe:22 cwe:73 reason:root is the user's own OneDrive root from their environment joined with the hardcoded "Documents" segment; resolving the user's own profile location is the purpose of `supply-chain init` + docs := filepath.Join(root, "Documents") + if fileExists(docs) { + return docs + } + } + + return defaultDocs +} + func GenerateWrapper(shell string, pms []string) string { cli := resolveCliPath() switch shell { case shellFish: return generateFishWrapper(pms, cli) + case shellPwsh, shellWindowsPowerShell: + return generatePowerShellWrapper(pms, cli) default: return generatePosixWrapper(pms, cli) } } +// ShellReloadCommand returns the command a user runs to load the freshly-written +// wrapper block into their current session without restarting the shell. POSIX +// shells and fish source the file (`source `); PowerShell dot-sources it +// (`. `). init prints this per detected shell after injection. +func ShellReloadCommand(shell, rcFile string) string { + switch shell { + case shellPwsh, shellWindowsPowerShell: + return ". " + rcFile + default: + return "source " + rcFile + } +} + +// IsPowerShell reports whether name is one of the PowerShell editions (pwsh or +// Windows PowerShell). It lets the cmd layer reason about PowerShell-specific +// guidance (e.g. the dotted-pip-variant note) without hardcoding the shell-name +// literals that this package owns. +func IsPowerShell(name string) bool { + return name == shellPwsh || name == shellWindowsPowerShell +} + // generatePosixWrapper builds the bash/zsh wrapper block for the given PMs. // armis:ignore cwe:770 reason:sanitizePMNames caps the name list at maxPMNames (16), so the string builder cannot grow without bound; pms also originates from local lockfile detection (≤4 ecosystems) rather than untrusted input func generatePosixWrapper(pms []string, cli string) string { @@ -193,10 +291,68 @@ func generateFishWrapper(pms []string, cli string) string { return b.String() } +// generatePowerShellWrapper builds the PowerShell wrapper block for the given +// PMs. Both PowerShell editions (pwsh, Windows PowerShell) share this output — +// the syntax is identical; only profile-path selection differs in DetectShells. +// armis:ignore cwe:770 reason:sanitizePMNames caps the name list at maxPMNames (16), so the string builder cannot grow without bound; pms also originates from local PATH detection (≤4 ecosystems) rather than untrusted input +func generatePowerShellWrapper(pms []string, cli string) string { + safeCli := shellQuotePowerShell(cli) + var b strings.Builder + b.WriteString(markerStart + "\n") + for _, pm := range sanitizePMNames(pms) { + // PowerShell function names cannot contain a dot, so versioned pip + // variants (pip3.12) cannot be wrapped here. Skip them — `pip` and `pip3` + // cover the common case, and the init command surfaces a one-line note so + // the user knows the dotted variant was left unwrapped rather than silently + // dropped. The non-dotted names still flow through sanitizePMNames above. + if strings.Contains(pm, ".") { + continue + } + // Recursion guard + presence check in one construct: `Get-Command + // -CommandType Application` resolves only external executables, so it never + // re-enters the wrapper function itself (the PowerShell analogue of bash + // `command`). It accepts both a bare name and an absolute path, so unlike + // the POSIX wrapper it needs no separate Test-Path branch. + // + // Fail-closed: when armis-cli is not resolvable (e.g. a stale absolute path + // left by a package-manager upgrade), warn on real stderr via + // [Console]::Error.WriteLine — not Write-Error/Write-Host, which write to + // the PowerShell error/information streams rather than the process's stderr + // — and run the real package manager unwrapped rather than failing outright. + // + // @args splats the caller's positional arguments. This works because the + // wrapper is a simple function (no [CmdletBinding()]). $__armis_cli uses a + // double-underscore prefix to avoid colliding with a user variable. + // armis:ignore cwe:78 reason:pm is constrained to ^[a-z][a-z0-9-]*$ here (sanitizePMNames plus the dot filter above removes any dotted variant), so no shell metacharacter can reach the script; safeCli is shellQuotePowerShell-escaped; Get-Command is used only for presence detection + fmt.Fprintf(&b, + "function %s {\n"+ + " $__armis_cli = Get-Command %s -CommandType Application -ErrorAction SilentlyContinue\n"+ + " if ($__armis_cli) {\n"+ + " & $__armis_cli.Source supply-chain wrap %s @args\n"+ + " } else {\n"+ + " [Console]::Error.WriteLine('[armis] armis-cli not found - running %s WITHOUT supply-chain enforcement')\n"+ + " & (Get-Command %s -CommandType Application).Source @args\n"+ + " }\n"+ + "}\n", + pm, safeCli, pm, pm, pm) + } + b.WriteString(markerEnd + "\n") + return b.String() +} + func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +// shellQuotePowerShell wraps s in a PowerShell single-quoted literal, doubling +// any embedded single quote (two single quotes escape one inside a single-quoted +// string). Single quotes suppress all PowerShell interpolation, so the embedded +// path is treated verbatim — the analogue of POSIX shellQuote but with +// PowerShell's distinct escaping rule. +func shellQuotePowerShell(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + // resolveCliPath returns the most upgrade-proof reference to the armis-cli // binary to embed in the generated shell wrapper functions. // diff --git a/internal/supplychain/shell_test.go b/internal/supplychain/shell_test.go index 957bd2e..ff8871a 100644 --- a/internal/supplychain/shell_test.go +++ b/internal/supplychain/shell_test.go @@ -39,6 +39,77 @@ func TestGenerateWrapper_Fish(t *testing.T) { } } +// TestGenerateWrapper_PowerShell verifies the PowerShell wrapper carries the +// marker block and emits a `function npm {` declaration that routes through +// `supply-chain wrap npm @args`. Both PowerShell editions (pwsh and Windows +// PowerShell) must produce byte-identical output — the syntax is the same and +// only profile-path selection differs in DetectShells. +func TestGenerateWrapper_PowerShell(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"npm"}) + + if !strings.Contains(wrapper, markerStart) { + t.Error("missing start marker") + } + if !strings.Contains(wrapper, markerEnd) { + t.Error("missing end marker") + } + if !strings.Contains(wrapper, "function npm {") { + t.Errorf("missing PowerShell function declaration:\n%s", wrapper) + } + if !strings.Contains(wrapper, "supply-chain wrap npm @args") { + t.Errorf("unexpected PowerShell wrapper: %s", wrapper) + } + + // pwsh and powershell are the same script; only the profile path differs. + if other := GenerateWrapper(shellWindowsPowerShell, []string{"npm"}); other != wrapper { + t.Errorf("pwsh and powershell wrappers differ:\npwsh:\n%s\npowershell:\n%s", wrapper, other) + } +} + +// TestGenerateWrapper_PowerShellContainsGuard verifies the PowerShell wrapper is +// fail-closed and recursion-safe: it resolves armis-cli via `Get-Command +// -CommandType Application` (which bypasses the wrapper function itself), warns +// on real stderr via [Console]::Error.WriteLine when the binary is missing, and +// falls back to running the real package manager through its Application +// resolution so an install never silently breaks after an armis-cli upgrade. +func TestGenerateWrapper_PowerShellContainsGuard(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"npm"}) + + if !strings.Contains(wrapper, "Get-Command") { + t.Errorf("PowerShell wrapper missing `Get-Command` guard:\n%s", wrapper) + } + if !strings.Contains(wrapper, "-CommandType Application") { + t.Errorf("PowerShell wrapper missing `-CommandType Application` recursion guard:\n%s", wrapper) + } + if !strings.Contains(wrapper, "armis-cli not found") { + t.Errorf("PowerShell wrapper missing the missing-binary warning:\n%s", wrapper) + } + if !strings.Contains(wrapper, "[Console]::Error.WriteLine") { + t.Errorf("PowerShell wrapper must warn on real stderr via [Console]::Error.WriteLine:\n%s", wrapper) + } + if !strings.Contains(wrapper, "& (Get-Command npm -CommandType Application).Source @args") { + t.Errorf("PowerShell wrapper missing the un-wrapped fallback for npm:\n%s", wrapper) + } +} + +// TestGenerateWrapper_PowerShellSkipsDottedNames verifies that a versioned pip +// variant (pip3.12) is omitted from the PowerShell wrapper — PowerShell function +// names cannot contain a dot — while the non-dotted pip and pip3 are still +// emitted as wrapper functions. +func TestGenerateWrapper_PowerShellSkipsDottedNames(t *testing.T) { + wrapper := GenerateWrapper(shellPwsh, []string{"pip", "pip3", "pip3.12"}) + + if strings.Contains(wrapper, "pip3.12") { + t.Errorf("PowerShell wrapper must skip the dotted variant pip3.12:\n%s", wrapper) + } + if !strings.Contains(wrapper, "function pip {") { + t.Errorf("PowerShell wrapper missing `function pip {`:\n%s", wrapper) + } + if !strings.Contains(wrapper, "function pip3 {") { + t.Errorf("PowerShell wrapper missing `function pip3 {`:\n%s", wrapper) + } +} + func TestGenerateWrapper_MultiplePMs(t *testing.T) { wrapper := GenerateWrapper(shellZsh, []string{"npm", "npx"}) @@ -67,7 +138,7 @@ func TestGenerateWrapper_RejectsUnsafeNames(t *testing.T) { "npm'quote", // embedded quote } - for _, shell := range []string{shellBash, shellZsh, shellFish} { + for _, shell := range []string{shellBash, shellZsh, shellFish, shellPwsh, shellWindowsPowerShell} { for _, name := range malicious { wrapper := GenerateWrapper(shell, []string{name}) // The only content should be the marker block; the unsafe name must @@ -494,6 +565,126 @@ func TestDetectShells(t *testing.T) { }) } +// TestDetectShells_PowerShell verifies pwsh is detected when its executable is on +// PATH even though no profile file exists yet (the common first-run case). It is +// gated to non-Windows because it seeds a POSIX-style $PATH and relies on the +// non-Windows powershellProfiles layout (~/.config/powershell). +func TestDetectShells_PowerShell(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("seeds a POSIX $PATH and the ~/.config/powershell layout; Windows uses a different profile path") + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "") // no POSIX shell, so only the PowerShell pass can match + + // Seed a pwsh executable on PATH; its profile is deliberately absent. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, shellPwsh), []byte{}, 0o755); err != nil { //nolint:gosec + t.Fatalf("seed pwsh: %v", err) + } + t.Setenv("PATH", dir) + + shells := DetectShells() + var found bool + for _, sh := range shells { + if sh.Name == shellPwsh { + found = true + // The profile path is the host-agnostic CurrentUserAllHosts profile. + want := filepath.Join(home, ".config", "powershell", "profile.ps1") + if sh.RCFile != want { + t.Errorf("pwsh RCFile = %q, want %q", sh.RCFile, want) + } + } + } + if !found { + t.Errorf("expected pwsh among detected shells when on PATH, got %v", shells) + } +} + +// TestDetectShells_PowerShellProfileExists verifies pwsh is detected when its +// profile file already exists even though the executable is not on PATH — the +// profile's presence alone qualifies it. Non-Windows-gated because it depends on +// the ~/.config/powershell layout and a POSIX-style empty $PATH. +func TestDetectShells_PowerShellProfileExists(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("depends on the ~/.config/powershell profile layout, which is Unix-only") + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SHELL", "") + t.Setenv("PATH", t.TempDir()) // empty dir: pwsh is NOT on PATH + + // Create the profile so detection must qualify it via fileExists, not PATH. + profileDir := filepath.Join(home, ".config", "powershell") + if err := os.MkdirAll(profileDir, 0o750); err != nil { + t.Fatalf("mkdir profile dir: %v", err) + } + profile := filepath.Join(profileDir, "profile.ps1") + if err := os.WriteFile(profile, []byte("# profile\n"), 0o600); err != nil { + t.Fatalf("seed profile: %v", err) + } + + shells := DetectShells() + var found bool + for _, sh := range shells { + if sh.Name == shellPwsh && sh.RCFile == profile { + found = true + } + } + if !found { + t.Errorf("expected pwsh detected via existing profile, got %v", shells) + } +} + +// TestResolveWindowsDocumentsDir exercises the OneDrive-redirection resolution. +// It is Windows-gated (like TestRemoveFunctions_PreservesPermissions skips +// non-applicable platforms) because the helper only runs on the Windows +// detection path and the Documents/OneDrive layout it resolves is Windows-shaped. +func TestResolveWindowsDocumentsDir(t *testing.T) { + if runtime.GOOS != goosWindows { + t.Skip("resolveWindowsDocumentsDir is only used on the Windows detection path") + } + + t.Run("prefers existing home Documents", func(t *testing.T) { + home := t.TempDir() + docs := filepath.Join(home, "Documents") + if err := os.MkdirAll(docs, 0o750); err != nil { + t.Fatal(err) + } + if got := resolveWindowsDocumentsDir(home); got != docs { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q", got, docs) + } + }) + + t.Run("falls back to OneDrive Documents when home Documents is absent", func(t *testing.T) { + home := t.TempDir() // no Documents subdir here + oneDriveRoot := t.TempDir() // simulate the OneDrive root + t.Setenv("OneDriveConsumer", "") + t.Setenv("OneDrive", oneDriveRoot) + t.Setenv("OneDriveCommercial", "") + odDocs := filepath.Join(oneDriveRoot, "Documents") + if err := os.MkdirAll(odDocs, 0o750); err != nil { + t.Fatal(err) + } + if got := resolveWindowsDocumentsDir(home); got != odDocs { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q", got, odDocs) + } + }) + + t.Run("falls back to home Documents when nothing exists", func(t *testing.T) { + home := t.TempDir() + t.Setenv("OneDriveConsumer", "") + t.Setenv("OneDrive", "") + t.Setenv("OneDriveCommercial", "") + want := filepath.Join(home, "Documents") + if got := resolveWindowsDocumentsDir(home); got != want { + t.Errorf("resolveWindowsDocumentsDir = %q, want %q (not-yet-existing default)", got, want) + } + }) +} + // TestGenerateWrapper_PosixContainsGuard verifies the bash/zsh wrapper is // fail-closed: it guards the armis-cli invocation with a `command -v` PATH lookup // (alongside an `[ -x ]` absolute-path check), warns on stderr when the binary is @@ -568,6 +759,29 @@ func TestGenerateWrapper_AbsolutePathGuardChecksExecutable(t *testing.T) { } } +// TestShellReloadCommand verifies the per-shell reload hint: POSIX shells and +// fish source the file, while both PowerShell editions dot-source it. +func TestShellReloadCommand(t *testing.T) { + const rc = "/home/u/.rc" + tests := []struct { + shell string + want string + }{ + {shellBash, "source " + rc}, + {shellZsh, "source " + rc}, + {shellFish, "source " + rc}, + {shellPwsh, ". " + rc}, + {shellWindowsPowerShell, ". " + rc}, + } + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + if got := ShellReloadCommand(tt.shell, rc); got != tt.want { + t.Errorf("ShellReloadCommand(%q, %q) = %q, want %q", tt.shell, rc, got, tt.want) + } + }) + } +} + // TestGenerateWrapper_WarningReferencesPMName verifies the fallback warning names // the package manager the user actually invoked (not a hard-coded "npm"), for // every shell and including versioned pip variants. @@ -581,6 +795,19 @@ func TestGenerateWrapper_WarningReferencesPMName(t *testing.T) { } } } + + // PowerShell cannot define dotted function names, so pip3.12 is skipped (covered + // by TestGenerateWrapper_PowerShellSkipsDottedNames). For the names it does wrap, + // the same per-PM warning must name the invoked command. + for _, shell := range []string{shellPwsh, shellWindowsPowerShell} { + for _, pm := range []string{"npm", "pip3", "poetry"} { + wrapper := GenerateWrapper(shell, []string{pm}) + want := "running " + pm + " WITHOUT supply-chain enforcement" + if !strings.Contains(wrapper, want) { + t.Errorf("%s wrapper for %q missing PM name in warning %q:\n%s", shell, pm, want, wrapper) + } + } + } } // TestResolveCliPath_PrefersBareNameWhenOnPath verifies that when armis-cli is @@ -644,6 +871,19 @@ func TestWrappedPMs(t *testing.T) { } }) + t.Run("parses the PMs from a PowerShell block", func(t *testing.T) { + // The wrappedPMLine regex matches `function name {` regardless of the + // trailing brace, so a PowerShell block round-trips through WrappedPMs the + // same way fish does. pip3.12 is skipped by the generator (dotted names are + // unsupported in PowerShell), so it must not appear in the parsed set. + rc := writeRC(t, GenerateWrapper(shellPwsh, []string{"npm", "pip", "pip3.12"})) + got := WrappedPMs(rc) + want := []string{"npm", "pip"} + if !slices.Equal(got, want) { + t.Errorf("WrappedPMs = %v, want %v", got, want) + } + }) + t.Run("ignores user functions outside the injected block", func(t *testing.T) { // A user's own npm function defined before the marker must not be reported // as armis-wrapped — only declarations inside the marked block count. From 189cbcd1d720f610365c5b25a13ca3285e7b1171 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 30 Jun 2026 14:45:39 +0200 Subject: [PATCH 2/5] docs: update CHANGELOG for PowerShell supply-chain init (PPSC-1065) --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 23e5011..32f1bfe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `supply-chain init`: PowerShell profile injection is now supported. On machines where PowerShell is detected, `init` writes wrapper functions into the PowerShell profile (`~/.config/powershell/Microsoft.PowerShell_profile.ps1` on Unix, or `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` on Windows). Package managers with dotted names (e.g. `pip3.12`) are skipped in the PowerShell profile — PowerShell function names may not contain dots — and a muted note explains the skip while confirming `pip` and `pip3` are still wrapped. (PPSC-1065) - `supply-chain`: a wrapped install that fails right after the age filter now names the likely culprit instead of a generic note. The proxy is graph-blind — when it withholds a brand-new release and repoints `latest` to an older version, that older version may no longer satisfy a dependent's range, and npm/pnpm/bun/yarn then reject the install. On the npm family a deterministic post-install, one-hop constraint check reports exactly which dependency became unsatisfiable and which package required it (e.g. `scheduler has no version older than the 3-day policy that satisfies ^0.24.0 (required by react-dom)`). The check reads dependency ranges npm already embeds in the metadata the proxy fetched — it is one hop, advisory, and recover-guarded so it can never affect the finished install; multi-hop chains and full resolution are out of scope. pip/uv get the blocked-package name plus a pointer to `uv tree`/`pipdeptree` (PyPI's Simple API carries no dependency ranges). The failure note leads with the protection rationale and lays out remediation surgical-first (allow one package → team exception → relax window), deliberately omitting the global kill switch so a frustrated developer isn't nudged to the blunt instrument. (PPSC-984) - `supply-chain`: a machine-readable compliance report for audit trails ("prove no young package entered this build"). Set `ARMIS_SUPPLY_CHAIN_REPORT=` for a wrapped install (`-` writes to stderr), or pass `--report ` to `supply-chain check`. The JSON carries the effective policy, the enforcement mode (`proxy`/`pre-install`/`check`), and the `checked`/`blocked`/`resolved`/`warned_through`/`conflicts` sets plus `install_status`, so CI can gate with `jq`. A wrap report uses an env var, not a flag, because the wrapped command forwards every flag verbatim to the underlying package manager. (PPSC-984) - `supply-chain`: an opt-in `transitive-policy: warn` config key (and `ARMIS_SUPPLY_CHAIN_TRANSITIVE=warn` env override for the wrap path) that lets a young **transitive** dependency through with a warning instead of failing the build, while still hard-blocking young **direct** dependencies. The default stays `block` — no posture change without opt-in. Direct vs. transitive is determined by reading the root `package.json` (npm family only); if the direct set can't be determined the proxy fails safe and treats every package as direct (blocks). Each warned-through package is printed and marked in the compliance report so security teams can audit exactly which freshly-published packages entered the build. Residual risk and scope are documented in `docs/FEATURES.md`. (PPSC-984) From eaff3037c0b7d76c75cd5b03d2b16d6bb1c8cbfd Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 30 Jun 2026 14:51:00 +0200 Subject: [PATCH 3/5] test(supply-chain): isolate PATH in DetectShells empty-env test GitHub-hosted macOS and Ubuntu runners ship with pwsh pre-installed, so IsOnPath("pwsh") fired even when HOME/SHELL were cleared. Clear PATH too so the test truly isolates an environment with no shells available. --- internal/supplychain/shell_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/supplychain/shell_test.go b/internal/supplychain/shell_test.go index ff8871a..7660b99 100644 --- a/internal/supplychain/shell_test.go +++ b/internal/supplychain/shell_test.go @@ -559,6 +559,7 @@ func TestDetectShells(t *testing.T) { t.Run("no RC files and no SHELL yields nothing", func(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("SHELL", "") + t.Setenv("PATH", "") // prevent pwsh on the runner's PATH from triggering PowerShell detection if shells := DetectShells(); len(shells) != 0 { t.Errorf("expected no shells for an empty home with no $SHELL, got %v", shells) } From aeb58033a6194230f472b488db9307e8e201183b Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 30 Jun 2026 14:59:11 +0200 Subject: [PATCH 4/5] fix(supply-chain): address Copilot review findings - Quote PowerShell profile path in ShellReloadCommand to handle paths with spaces (e.g. OneDrive-redirected Documents on Windows) - Fix misleading pip note: only claim non-dotted alternatives are wrapped if they are actually present in the detected PM list - Correct CHANGELOG profile paths: profile.ps1 (CurrentUserAllHosts), not Microsoft.PowerShell_profile.ps1 (CurrentUserCurrentHost); add Windows PowerShell 5.1 path - Suppress CWE-78 FP on ShellReloadCommand return value (display-only, never executed; rcFile derives from hardcoded HOME-relative paths) --- docs/CHANGELOG.md | 2 +- internal/cmd/supply_chain_init.go | 14 ++++++++++++-- internal/supplychain/shell.go | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 32f1bfe..d5a3ed9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `supply-chain init`: PowerShell profile injection is now supported. On machines where PowerShell is detected, `init` writes wrapper functions into the PowerShell profile (`~/.config/powershell/Microsoft.PowerShell_profile.ps1` on Unix, or `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` on Windows). Package managers with dotted names (e.g. `pip3.12`) are skipped in the PowerShell profile — PowerShell function names may not contain dots — and a muted note explains the skip while confirming `pip` and `pip3` are still wrapped. (PPSC-1065) +- `supply-chain init`: PowerShell profile injection is now supported. On machines where PowerShell (`pwsh`) is detected, `init` writes wrapper functions into the `CurrentUserAllHosts` profile (`~/.config/powershell/profile.ps1` on Unix/macOS, `Documents\PowerShell\profile.ps1` for pwsh on Windows, or `Documents\WindowsPowerShell\profile.ps1` for Windows PowerShell 5.1). Package managers with dotted names (e.g. `pip3.12`) are skipped in the PowerShell profile — PowerShell function names may not contain dots — and a muted note explains the skip, listing any non-dotted alternatives that are still wrapped. (PPSC-1065) - `supply-chain`: a wrapped install that fails right after the age filter now names the likely culprit instead of a generic note. The proxy is graph-blind — when it withholds a brand-new release and repoints `latest` to an older version, that older version may no longer satisfy a dependent's range, and npm/pnpm/bun/yarn then reject the install. On the npm family a deterministic post-install, one-hop constraint check reports exactly which dependency became unsatisfiable and which package required it (e.g. `scheduler has no version older than the 3-day policy that satisfies ^0.24.0 (required by react-dom)`). The check reads dependency ranges npm already embeds in the metadata the proxy fetched — it is one hop, advisory, and recover-guarded so it can never affect the finished install; multi-hop chains and full resolution are out of scope. pip/uv get the blocked-package name plus a pointer to `uv tree`/`pipdeptree` (PyPI's Simple API carries no dependency ranges). The failure note leads with the protection rationale and lays out remediation surgical-first (allow one package → team exception → relax window), deliberately omitting the global kill switch so a frustrated developer isn't nudged to the blunt instrument. (PPSC-984) - `supply-chain`: a machine-readable compliance report for audit trails ("prove no young package entered this build"). Set `ARMIS_SUPPLY_CHAIN_REPORT=` for a wrapped install (`-` writes to stderr), or pass `--report ` to `supply-chain check`. The JSON carries the effective policy, the enforcement mode (`proxy`/`pre-install`/`check`), and the `checked`/`blocked`/`resolved`/`warned_through`/`conflicts` sets plus `install_status`, so CI can gate with `jq`. A wrap report uses an env var, not a flag, because the wrapped command forwards every flag verbatim to the underlying package manager. (PPSC-984) - `supply-chain`: an opt-in `transitive-policy: warn` config key (and `ARMIS_SUPPLY_CHAIN_TRANSITIVE=warn` env override for the wrap path) that lets a young **transitive** dependency through with a warning instead of failing the build, while still hard-blocking young **direct** dependencies. The default stays `block` — no posture change without opt-in. Direct vs. transitive is determined by reading the root `package.json` (npm family only); if the direct set can't be determined the proxy fails safe and treats every package as direct (blocks). Each warned-through package is printed and marked in the compliance report so security teams can audit exactly which freshly-published packages entered the build. Residual risk and scope are documented in `docs/FEATURES.md`. (PPSC-984) diff --git a/internal/cmd/supply_chain_init.go b/internal/cmd/supply_chain_init.go index e66a2ea..dd42180 100644 --- a/internal/cmd/supply_chain_init.go +++ b/internal/cmd/supply_chain_init.go @@ -516,9 +516,19 @@ func runInitRC(pms []string) error { // dotted variant is actually present — so the user knows the skip is intentional // and that pip/pip3 still cover the common case. if dotted := powerShellSkippedDottedPMs(pms, shells); len(dotted) > 0 { + var nonDottedPip []string + for _, pm := range pms { + if (pm == "pip" || pm == "pip3") && !strings.Contains(pm, ".") { + nonDottedPip = append(nonDottedPip, pm) + } + } + var suffix string + if len(nonDottedPip) > 0 { + suffix = fmt.Sprintf("; %s are wrapped instead", strings.Join(nonDottedPip, " and ")) + } fmt.Fprintf(os.Stderr, "%s\n\n", s.MutedText.Render(fmt.Sprintf( - "Note: %s can't be wrapped in PowerShell (dotted function names are unsupported); pip and pip3 are wrapped instead.", - strings.Join(dotted, ", ")))) + "Note: %s can't be wrapped in PowerShell (dotted function names are unsupported)%s.", + strings.Join(dotted, ", "), suffix))) } // Preview each distinct wrapper. bash/zsh share the posix wrapper while fish diff --git a/internal/supplychain/shell.go b/internal/supplychain/shell.go index 2d8b328..2211bb3 100644 --- a/internal/supplychain/shell.go +++ b/internal/supplychain/shell.go @@ -208,7 +208,8 @@ func GenerateWrapper(shell string, pms []string) string { func ShellReloadCommand(shell, rcFile string) string { switch shell { case shellPwsh, shellWindowsPowerShell: - return ". " + rcFile + // armis:ignore cwe:78 cwe:77 cwe:94 reason:rcFile derives from powershellProfiles() which joins $HOME/Documents with hardcoded path segments; the return value is printed to stderr as a human-readable suggestion and never executed programmatically + return ". " + shellQuotePowerShell(rcFile) default: return "source " + rcFile } From 02a26be8e1036ef83a6c814f9c49b14dbc6aead7 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Tue, 30 Jun 2026 15:04:50 +0200 Subject: [PATCH 5/5] test(supply-chain): update TestShellReloadCommand expectations for quoted PowerShell path ShellReloadCommand now wraps the profile path with shellQuotePowerShell so paths containing spaces (e.g. OneDrive Documents redirects) work correctly. Update the test want strings to match the quoted output. --- internal/supplychain/shell_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/supplychain/shell_test.go b/internal/supplychain/shell_test.go index 7660b99..ecc6b78 100644 --- a/internal/supplychain/shell_test.go +++ b/internal/supplychain/shell_test.go @@ -771,8 +771,8 @@ func TestShellReloadCommand(t *testing.T) { {shellBash, "source " + rc}, {shellZsh, "source " + rc}, {shellFish, "source " + rc}, - {shellPwsh, ". " + rc}, - {shellWindowsPowerShell, ". " + rc}, + {shellPwsh, ". '" + rc + "'"}, + {shellWindowsPowerShell, ". '" + rc + "'"}, } for _, tt := range tests { t.Run(tt.shell, func(t *testing.T) {