Add support for package executors and support for PTY handling (#100)

* define contract for package executors

* introduce npx executor

* add npx and pnpx cmd support

* fix typo

* rm PackageExecutor and depend on PackageManager interface

* add support for PTY to handle parent-child process interaction

* refactor PTY handling in proxy flow

* enforce interactiveSession interface check

* close reader explicitly and clean npm version for pkg executors

* rm interaction from interceptors

* add docs and wait for outputRouter before exit

* add support for non interactive TTY for proxy mode

* add support for CI env var check for non interactive tty proxy mode

* update readme to include npx, pnpx support

* Update internal/flows/proxy_flow.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>

* update ptyx lib

* fix docs typo

---------

Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sahil Bansal
2026-01-09 22:03:42 +05:30
committed by GitHub
co-authored by Copilot
parent a373b5b243
commit 31f23fd065
21 changed files with 1009 additions and 96 deletions
+175 -51
View File
@@ -2,16 +2,20 @@ package flows
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/internal/pty"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
"github.com/safedep/pmg/proxy"
@@ -34,6 +38,15 @@ func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.
// Run executes the proxy-based flow
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
// Get the ecosystem from the package manager
ecosystem := f.pm.Ecosystem()
// Check if proxy mode is supported for this ecosystem
if !interceptors.IsSupported(ecosystem) {
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
}
cfg := config.Get()
// Check if dry-run mode is enabled
@@ -82,24 +95,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
defer close(confirmationChan)
// Create interaction callbacks for user prompts
interaction := guard.PackageManagerGuardInteraction{
SetStatus: ui.SetStatus,
ClearStatus: ui.ClearStatus,
ShowWarning: ui.ShowWarning,
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
Block: ui.Block,
}
// Get the ecosystem from the package manager
ecosystem := f.pm.Ecosystem()
// Check if proxy mode is supported for this ecosystem
if !interceptors.IsSupported(ecosystem) {
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
// Note: We use a pointer so we can later inject the input reader via SetInput
interaction := &guard.PackageManagerGuardInteraction{
SetStatus: ui.SetStatus,
ClearStatus: ui.ClearStatus,
ShowWarning: ui.ShowWarning,
Block: ui.Block,
}
// Create ecosystem-specific interceptor using factory
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan, interaction)
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan)
interceptor, err := factory.CreateInterceptor(ecosystem)
if err != nil {
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
@@ -127,8 +132,15 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
log.Infof("Proxy server started on %s", proxyAddr)
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
proxyEnv := f.setupEnvForProxy(proxyAddr, caCertPath)
if !pty.IsInteractiveTerminal() {
// Execute the package manager command with proxy environment variables for non PTY or non-interactive TTY
return f.executeWithProxyForNonInteractiveTTY(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
}
// Execute the package manager command with proxy environment variables
return f.executeWithProxy(ctx, parsedCmd, proxyAddr, caCertPath, confirmationChan, interaction)
return f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
}
// setupCACertificate generates or loads a CA certificate for MITM
@@ -211,21 +223,11 @@ func (f *proxyFlow) createAndStartProxyServer(
return proxyServer, proxyAddr, nil
}
// executeWithProxy executes the package manager command with proxy environment variables
func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemanager.ParsedCommand,
proxyAddr, caCertPath string, confirmationChan chan *interceptors.ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) error {
// Build proxy URL
func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string {
proxyURL := fmt.Sprintf("http://%s", proxyAddr)
// Create command
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
// Set proxy environment variables. This is what tells the executed command to use the proxy for communication.
// However, every package manager has its nuances and may require additional environment variables to be set.
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env,
env := os.Environ()
env = append(env,
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
@@ -237,32 +239,35 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
)
return env
}
// executeWithProxyForNonInteractiveTTY runs the command without PTY (for CI/non-interactive environments)
func (f *proxyFlow) executeWithProxyForNonInteractiveTTY(
ctx context.Context,
parsedCmd *packagemanager.ParsedCommand,
env []string,
confirmationChan chan *interceptors.ConfirmationRequest,
interaction *guard.PackageManagerGuardInteraction,
) error {
log.Debugf("Executing proxy for non interactive TTY")
// For non-interactive terminals, we enforce suspicious packages as malicious
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
return false, nil
}
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
cmd.Env = append(env, "CI=true")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
// Start confirmation handler in goroutine. Use confirmation hooks to pause and resume the executed
// process to prevent stdout and stderr from being mixed up. Pause / resume is on a best effort basis.
// We do not consider it a critical error if pause / resume fails.
go interceptors.HandleConfirmationRequests(confirmationChan, interaction, &interceptors.ConfirmationHook{
BeforeInteraction: func([]*analyzer.PackageVersionAnalysisResult) error {
if err := platformPauseProcess(cmd); err != nil {
log.Warnf("Failed to pause process for user interaction: %v", err)
}
return nil
},
AfterInteraction: func([]*analyzer.PackageVersionAnalysisResult, bool) error {
if err := platformResumeProcess(cmd); err != nil {
log.Warnf("Failed to resume process after user interaction: %v", err)
}
return nil
},
})
go interceptors.HandleConfirmationRequests(
confirmationChan,
interaction,
nil,
)
err := cmd.Run()
if err != nil {
@@ -276,3 +281,122 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
log.Debugf("Command completed successfully")
return nil
}
// executeWithProxy executes the package manager command with proxy environment variables.
func (f *proxyFlow) executeWithProxy(
ctx context.Context,
parsedCmd *packagemanager.ParsedCommand,
env []string,
confirmationChan chan *interceptors.ConfirmationRequest,
interaction *guard.PackageManagerGuardInteraction,
) error {
log.Debugf("Executing proxy for interactive TTY")
// Set the confirmation handler to use the interaction's reader
// This allows PTY input routing during proxy mode
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
return ui.GetConfirmationOnMalwareWithReader(malwarePackages, interaction.Reader())
}
sessionConfig := pty.NewSessionConfig(parsedCmd.Command.Exe, parsedCmd.Command.Args, env)
sess, err := pty.NewSession(ctx, sessionConfig)
if err != nil {
return fmt.Errorf("failed to create pty session: %w", err)
}
defer sess.Close()
outputRouter, err := pty.NewOutputRouter(os.Stdout)
if err != nil {
return fmt.Errorf("failed to create output router: %w", err)
}
var wg sync.WaitGroup
wg.Go(func() {
io.Copy(outputRouter, sess.PtyReader())
})
inputRouter, err := pty.NewInputRouter(sess.PtyWriter())
if err != nil {
return fmt.Errorf("failed to create input router: %w", err)
}
promptReader, promptWriter := io.Pipe()
defer func() {
promptWriter.Close()
promptReader.Close()
}()
// Note: This goroutine cannot be cleanly cancelled because os.Stdin.Read() is
// a blocking syscall that doesn't support timeouts or cancellation. This is a
// known limitation. The goroutine will exit when the process terminates, which
// is acceptable for a CLI tool. For long-running servers, stdin reading should
// be handled differently.
go inputRouter.ReadLoop(os.Stdin)
go interceptors.HandleConfirmationRequests(
confirmationChan,
interaction,
&interceptors.ConfirmationHook{
BeforeInteraction: func(_ []*analyzer.PackageVersionAnalysisResult) error {
// Pause printing the child output
outputRouter.Pause()
// Restore "Cooked" mode so user can type normally with echo
if err := sess.SetCookedMode(); err != nil {
return fmt.Errorf("failed to set cooked mode: %w", err)
}
// Force cursor visible (ANSI escape sequence)
fmt.Fprint(os.Stdout, "\033[?25h")
// Switch Input: Route keystrokes to the Prompt Pipe
inputRouter.RouteToPrompt(promptWriter)
// Inject the Reader into the Interaction for the confirmation prompt
interaction.SetInput(promptReader)
return nil
},
AfterInteraction: func(_ []*analyzer.PackageVersionAnalysisResult, _ bool) error {
// Switch input back to PTY
inputRouter.RouteToPTY()
// Restore "Raw" mode for the PTY
if err := sess.SetRawMode(); err != nil {
return fmt.Errorf("failed to set raw mode: %w", err)
}
// Clear the interaction input (back to default)
interaction.SetInput(nil)
// Flush buffered output and resume live output
outputRouter.Resume()
return nil
},
},
)
err = sess.Wait()
// Wait for the routers to copy all the remaining data
wg.Wait()
if err != nil {
var exitErr *pty.ExitError
if errors.As(err, &exitErr) {
// Close writer and reader
promptWriter.Close()
promptReader.Close()
// Close the session
sess.Close()
os.Exit(exitErr.Code)
}
return err
}
return nil
}